Skip to content

Commit 0f0cd1f

Browse files
authored
ref(stdlib): Move crumbs to integration (#7161)
Migrate `http.client` breadcrumbs from `maybe_create_breadcrumbs_from_span` directly to the `StdlibIntegration`. As the flow is scattered (one point where a request starts, but multiple points where it might end), we save the breadcrumb data alongside the span and emit a breadcrumb whenever we detect a response is finished. Also: - Add tests to both the standard library test suite and requests. #### Issues Part of #7067
1 parent 2fef9bc commit 0f0cd1f

4 files changed

Lines changed: 347 additions & 16 deletions

File tree

sentry_sdk/integrations/stdlib.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sentry_sdk.tracing import Span
1414
from sentry_sdk.tracing_utils import (
1515
EnvironHeaders,
16+
add_http_breadcrumb,
1617
add_http_request_source,
1718
has_span_streaming_enabled,
1819
should_propagate_trace,
@@ -112,11 +113,21 @@ def putrequest(
112113
parsed_url = parse_url(real_url, sanitize=False)
113114

114115
span_streaming = has_span_streaming_enabled(client.options)
115-
span: "Union[Span, StreamedSpan, None]"
116+
span: "Union[Span, StreamedSpan, None]" = None
117+
breadcrumb: "dict[str, Any]" = {}
118+
116119
if span_streaming:
117-
if sentry_sdk.traces.get_current_span() is None:
118-
span = None
119-
else:
120+
breadcrumb[SPANDATA.HTTP_REQUEST_METHOD] = method
121+
if parsed_url is not None and should_send_default_pii():
122+
breadcrumb.update(
123+
{
124+
SPANDATA.URL_FRAGMENT: parsed_url.fragment,
125+
SPANDATA.URL_FULL: parsed_url.url,
126+
SPANDATA.URL_QUERY: parsed_url.query,
127+
}
128+
)
129+
130+
if sentry_sdk.traces.get_current_span() is not None:
120131
span = sentry_sdk.traces.start_span(
121132
name="%s %s"
122133
% (
@@ -136,6 +147,7 @@ def putrequest(
136147
span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query)
137148

138149
set_on_span = span.set_attribute
150+
139151
else:
140152
span = sentry_sdk.start_span(
141153
op=OP.HTTP_CLIENT,
@@ -145,17 +157,35 @@ def putrequest(
145157
)
146158

147159
span.set_data(SPANDATA.HTTP_METHOD, method)
160+
breadcrumb[SPANDATA.HTTP_METHOD] = method
161+
148162
if parsed_url is not None:
149163
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
150164
span.set_data("url", parsed_url.url)
151165
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
152166

167+
breadcrumb.update(
168+
{
169+
SPANDATA.HTTP_FRAGMENT: parsed_url.fragment,
170+
"url": parsed_url.url,
171+
SPANDATA.HTTP_QUERY: parsed_url.query,
172+
}
173+
)
174+
153175
set_on_span = span.set_data
154176

155177
# for proxies, these point to the proxy host/port
156-
if span and tunnel_host:
157-
set_on_span(SPANDATA.NETWORK_PEER_ADDRESS, self.host)
158-
set_on_span(SPANDATA.NETWORK_PEER_PORT, self.port)
178+
if tunnel_host:
179+
if span:
180+
set_on_span(SPANDATA.NETWORK_PEER_ADDRESS, self.host)
181+
set_on_span(SPANDATA.NETWORK_PEER_PORT, self.port)
182+
183+
breadcrumb.update(
184+
{
185+
SPANDATA.NETWORK_PEER_ADDRESS: self.host,
186+
SPANDATA.NETWORK_PEER_PORT: self.port,
187+
}
188+
)
159189

160190
rv = real_putrequest(self, method, url, *args, **kwargs)
161191

@@ -174,28 +204,46 @@ def putrequest(
174204
self.putheader(key, value)
175205

176206
self._sentrysdk_span = span # type: ignore[attr-defined]
207+
self._sentrysdk_breadcrumb = breadcrumb # type: ignore[attr-defined]
177208

178209
return rv
179210

180211
def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any":
181212
span = getattr(self, "_sentrysdk_span", None)
182-
183-
if span is None:
184-
return real_getresponse(self, *args, **kwargs)
213+
breadcrumb = getattr(self, "_sentrysdk_breadcrumb", None)
185214

186215
try:
187216
rv = real_getresponse(self, *args, **kwargs)
188-
except BaseException:
189-
_complete_span(span)
217+
except BaseException as ex:
218+
if span:
219+
_complete_span(span)
220+
if (
221+
breadcrumb
222+
and "getresponse() got an unexpected keyword argument 'buffering'"
223+
not in str(ex)
224+
):
225+
# the exception msg check is needed for Python 3.6/requests compat
226+
add_http_breadcrumb(None, breadcrumb)
190227
raise
191228

229+
status_code = int(rv.status)
230+
231+
if breadcrumb:
232+
breadcrumb[SPANDATA.HTTP_STATUS_CODE] = status_code
233+
234+
if span is None:
235+
if breadcrumb:
236+
add_http_breadcrumb(status_code, breadcrumb)
237+
return rv
238+
192239
if isinstance(span, StreamedSpan):
193-
status_code = int(rv.status)
194240
span.status = "error" if status_code >= 400 else "ok"
195-
span.set_attribute("http.response.status_code", status_code)
196-
else:
197-
span.set_http_status(int(rv.status))
241+
span.set_attribute(SPANDATA.HTTP_STATUS_CODE, status_code)
242+
elif isinstance(span, Span):
243+
span.set_http_status(status_code)
198244
span.set_data("reason", rv.reason)
245+
if breadcrumb:
246+
breadcrumb["reason"] = rv.reason
199247

200248
# getresponse doesn't include actually reading the response body. This
201249
# is done in read(). So if the metadata/headers suggest there's a body to
@@ -206,6 +254,11 @@ def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any":
206254
else:
207255
_complete_span(span)
208256

257+
if breadcrumb:
258+
# Regardless of whether the response itself has been fully read or not,
259+
# the breadcrumb can now be emitted since we now have the status code.
260+
add_http_breadcrumb(status_code, breadcrumb)
261+
209262
return rv
210263

211264
def read(self: "HTTPResponse", *args: "Any", **kwargs: "Any") -> "Any":

sentry_sdk/tracing_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ def maybe_create_breadcrumbs_from_span(
233233
"auto.http.pyreqwest",
234234
"auto.http.httpx",
235235
"auto.http.httpx2",
236+
"auto.http.stdlib.httplib",
236237
):
237238
level = None
238239
status_code = span._data.get(SPANDATA.HTTP_STATUS_CODE)

tests/integrations/requests/test_requests.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,43 @@ def test_crumb_capture(sentry_init, capture_events):
3636
)
3737

3838

39+
@pytest.mark.parametrize("send_default_pii", [True, False])
40+
def test_crumb_capture_span_streaming(sentry_init, capture_events, send_default_pii):
41+
sentry_init(
42+
integrations=[StdlibIntegration()],
43+
send_default_pii=send_default_pii,
44+
trace_lifecycle="stream",
45+
)
46+
events = capture_events()
47+
48+
url = f"http://localhost:{PORT}/hello-world" # noqa:E231
49+
response = requests.get(url)
50+
capture_message("Testing!")
51+
52+
(event,) = events
53+
(crumb,) = event["breadcrumbs"]["values"]
54+
assert crumb["type"] == "http"
55+
assert crumb["category"] == "httplib"
56+
57+
if send_default_pii:
58+
assert crumb["data"] == ApproxDict(
59+
{
60+
SPANDATA.URL_FULL: url,
61+
SPANDATA.HTTP_REQUEST_METHOD: "GET",
62+
SPANDATA.URL_FRAGMENT: "",
63+
SPANDATA.URL_QUERY: "",
64+
SPANDATA.HTTP_STATUS_CODE: response.status_code,
65+
}
66+
)
67+
else:
68+
assert crumb["data"] == ApproxDict(
69+
{
70+
SPANDATA.HTTP_REQUEST_METHOD: "GET",
71+
SPANDATA.HTTP_STATUS_CODE: response.status_code,
72+
}
73+
)
74+
75+
3976
@pytest.mark.skipif(
4077
sys.version_info < (3, 7),
4178
reason="The response status is not set on the span early enough in 3.6",
@@ -84,6 +121,68 @@ def test_crumb_capture_client_error(sentry_init, capture_events, status_code, le
84121
)
85122

86123

124+
@pytest.mark.skipif(
125+
sys.version_info < (3, 7),
126+
reason="The response status is not set on the span early enough in 3.6",
127+
)
128+
@pytest.mark.parametrize(
129+
"status_code,level",
130+
[
131+
(200, None),
132+
(301, None),
133+
(403, "warning"),
134+
(405, "warning"),
135+
(500, "error"),
136+
],
137+
)
138+
@pytest.mark.parametrize("send_default_pii", [True, False])
139+
def test_crumb_capture_client_error_span_streaming(
140+
sentry_init, capture_events, status_code, level, send_default_pii
141+
):
142+
sentry_init(
143+
integrations=[StdlibIntegration()],
144+
send_default_pii=send_default_pii,
145+
trace_lifecycle="stream",
146+
)
147+
148+
events = capture_events()
149+
150+
url = f"http://localhost:{PORT}/status/{status_code}" # noqa:E231
151+
response = requests.get(url)
152+
153+
assert response.status_code == status_code
154+
155+
capture_message("Testing!")
156+
157+
(event,) = events
158+
(crumb,) = event["breadcrumbs"]["values"]
159+
assert crumb["type"] == "http"
160+
assert crumb["category"] == "httplib"
161+
162+
if level is None:
163+
assert "level" not in crumb
164+
else:
165+
assert crumb["level"] == level
166+
167+
if send_default_pii:
168+
assert crumb["data"] == ApproxDict(
169+
{
170+
SPANDATA.URL_FULL: url,
171+
SPANDATA.HTTP_REQUEST_METHOD: "GET",
172+
SPANDATA.URL_FRAGMENT: "",
173+
SPANDATA.URL_QUERY: "",
174+
SPANDATA.HTTP_STATUS_CODE: response.status_code,
175+
}
176+
)
177+
else:
178+
assert crumb["data"] == ApproxDict(
179+
{
180+
SPANDATA.HTTP_REQUEST_METHOD: "GET",
181+
SPANDATA.HTTP_STATUS_CODE: response.status_code,
182+
}
183+
)
184+
185+
87186
@pytest.mark.tests_internal_exceptions
88187
def test_omit_url_data_if_parsing_fails(sentry_init, capture_events):
89188
sentry_init(integrations=[StdlibIntegration()])
@@ -112,3 +211,36 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events):
112211
assert "url" not in event["breadcrumbs"]["values"][0]["data"]
113212
assert SPANDATA.HTTP_FRAGMENT not in event["breadcrumbs"]["values"][0]["data"]
114213
assert SPANDATA.HTTP_QUERY not in event["breadcrumbs"]["values"][0]["data"]
214+
215+
216+
@pytest.mark.tests_internal_exceptions
217+
def test_omit_url_data_if_parsing_fails_span_streaming(sentry_init, capture_events):
218+
sentry_init(
219+
integrations=[StdlibIntegration()],
220+
trace_lifecycle="stream",
221+
send_default_pii=True,
222+
)
223+
224+
events = capture_events()
225+
226+
url = f"http://localhost:{PORT}/ok" # noqa:E231
227+
228+
with mock.patch(
229+
"sentry_sdk.integrations.stdlib.parse_url",
230+
side_effect=ValueError,
231+
):
232+
response = requests.get(url)
233+
234+
capture_message("Testing!")
235+
236+
(event,) = events
237+
assert event["breadcrumbs"]["values"][0]["data"] == ApproxDict(
238+
{
239+
SPANDATA.HTTP_REQUEST_METHOD: "GET",
240+
SPANDATA.HTTP_STATUS_CODE: response.status_code,
241+
# no url related data
242+
}
243+
)
244+
assert SPANDATA.URL_FULL not in event["breadcrumbs"]["values"][0]["data"]
245+
assert SPANDATA.URL_FRAGMENT not in event["breadcrumbs"]["values"][0]["data"]
246+
assert SPANDATA.URL_QUERY not in event["breadcrumbs"]["values"][0]["data"]

0 commit comments

Comments
 (0)