Skip to content

Commit e61cadb

Browse files
feat(httpx): Add source information for slow outgoing HTTP requests
1 parent 3b054b6 commit e61cadb

File tree

5 files changed

+336
-4
lines changed

5 files changed

+336
-4
lines changed

sentry_sdk/integrations/httpx.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import sentry_sdk
2+
from sentry_sdk import start_span
23
from sentry_sdk.consts import OP, SPANDATA
34
from sentry_sdk.integrations import Integration, DidNotEnable
45
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
5-
from sentry_sdk.tracing_utils import Baggage, should_propagate_trace
6+
from sentry_sdk.tracing_utils import (
7+
Baggage,
8+
should_propagate_trace,
9+
add_http_request_source,
10+
)
611
from sentry_sdk.utils import (
712
SENSITIVE_DATA_SUBSTITUTE,
813
capture_internal_exceptions,
@@ -52,7 +57,7 @@ def send(self, request, **kwargs):
5257
with capture_internal_exceptions():
5358
parsed_url = parse_url(str(request.url), sanitize=False)
5459

55-
with sentry_sdk.start_span(
60+
with start_span(
5661
op=OP.HTTP_CLIENT,
5762
name="%s %s"
5863
% (
@@ -88,7 +93,10 @@ def send(self, request, **kwargs):
8893
span.set_http_status(rv.status_code)
8994
span.set_data("reason", rv.reason_phrase)
9095

91-
return rv
96+
with capture_internal_exceptions():
97+
add_http_request_source(span)
98+
99+
return rv
92100

93101
Client.send = send
94102

@@ -144,7 +152,10 @@ async def send(self, request, **kwargs):
144152
span.set_http_status(rv.status_code)
145153
span.set_data("reason", rv.reason_phrase)
146154

147-
return rv
155+
with capture_internal_exceptions():
156+
add_http_request_source(span)
157+
158+
return rv
148159

149160
AsyncClient.send = send
150161

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import os
2+
import sys
13
import pytest
24

35
pytest.importorskip("httpx")
6+
7+
# Load `httpx_helpers` into the module search path to test request source path names relative to module. See
8+
# `test_request_source_with_module_in_search_path`
9+
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))

tests/integrations/httpx/httpx_helpers/__init__.py

Whitespace-only changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def get_request_with_client(client, url):
2+
client.get(url)
3+
4+
5+
async def async_get_request_with_client(client, url):
6+
await client.get(url)

tests/integrations/httpx/test_httpx.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import os
2+
import datetime
13
import asyncio
24
from unittest import mock
35

46
import httpx
57
import pytest
8+
from contextlib import contextmanager
69

710
import sentry_sdk
811
from sentry_sdk import capture_message, start_transaction
@@ -393,6 +396,312 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events, httpx_mock)
393396
assert SPANDATA.HTTP_QUERY not in event["breadcrumbs"]["values"][0]["data"]
394397

395398

399+
@pytest.mark.parametrize(
400+
"httpx_client",
401+
(httpx.Client(), httpx.AsyncClient()),
402+
)
403+
def test_request_source_disabled(sentry_init, capture_events, httpx_client, httpx_mock):
404+
httpx_mock.add_response()
405+
sentry_init(
406+
integrations=[HttpxIntegration()],
407+
traces_sample_rate=1.0,
408+
enable_http_request_source=False,
409+
http_request_source_threshold_ms=0,
410+
)
411+
412+
events = capture_events()
413+
414+
url = "http://example.com/"
415+
416+
with start_transaction(name="test_transaction"):
417+
if asyncio.iscoroutinefunction(httpx_client.get):
418+
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
419+
else:
420+
httpx_client.get(url)
421+
422+
(event,) = events
423+
424+
span = event["spans"][-1]
425+
assert span["description"].startswith("GET")
426+
427+
data = span.get("data", {})
428+
429+
assert SPANDATA.CODE_LINENO not in data
430+
assert SPANDATA.CODE_NAMESPACE not in data
431+
assert SPANDATA.CODE_FILEPATH not in data
432+
assert SPANDATA.CODE_FUNCTION not in data
433+
434+
435+
@pytest.mark.parametrize("enable_http_request_source", [None, True])
436+
@pytest.mark.parametrize(
437+
"httpx_client",
438+
(httpx.Client(), httpx.AsyncClient()),
439+
)
440+
def test_request_source_enabled(
441+
sentry_init, capture_events, enable_http_request_source, httpx_client, httpx_mock
442+
):
443+
httpx_mock.add_response()
444+
sentry_options = {
445+
"integrations": [HttpxIntegration()],
446+
"traces_sample_rate": 1.0,
447+
"http_request_source_threshold_ms": 0,
448+
}
449+
if enable_http_request_source is not None:
450+
sentry_options["enable_http_request_source"] = enable_http_request_source
451+
452+
sentry_init(**sentry_options)
453+
454+
events = capture_events()
455+
456+
url = "http://example.com/"
457+
458+
with start_transaction(name="test_transaction"):
459+
if asyncio.iscoroutinefunction(httpx_client.get):
460+
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
461+
else:
462+
httpx_client.get(url)
463+
464+
(event,) = events
465+
466+
span = event["spans"][-1]
467+
assert span["description"].startswith("GET")
468+
469+
data = span.get("data", {})
470+
471+
assert SPANDATA.CODE_LINENO in data
472+
assert SPANDATA.CODE_NAMESPACE in data
473+
assert SPANDATA.CODE_FILEPATH in data
474+
assert SPANDATA.CODE_FUNCTION in data
475+
476+
477+
@pytest.mark.parametrize(
478+
"httpx_client",
479+
(httpx.Client(), httpx.AsyncClient()),
480+
)
481+
def test_request_source(sentry_init, capture_events, httpx_client, httpx_mock):
482+
httpx_mock.add_response()
483+
484+
sentry_init(
485+
integrations=[HttpxIntegration()],
486+
traces_sample_rate=1.0,
487+
enable_http_request_source=True,
488+
http_request_source_threshold_ms=0,
489+
)
490+
491+
events = capture_events()
492+
493+
url = "http://example.com/"
494+
495+
with start_transaction(name="test_transaction"):
496+
if asyncio.iscoroutinefunction(httpx_client.get):
497+
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
498+
else:
499+
httpx_client.get(url)
500+
501+
(event,) = events
502+
503+
span = event["spans"][-1]
504+
assert span["description"].startswith("GET")
505+
506+
data = span.get("data", {})
507+
508+
assert SPANDATA.CODE_LINENO in data
509+
assert SPANDATA.CODE_NAMESPACE in data
510+
assert SPANDATA.CODE_FILEPATH in data
511+
assert SPANDATA.CODE_FUNCTION in data
512+
513+
assert type(data.get(SPANDATA.CODE_LINENO)) == int
514+
assert data.get(SPANDATA.CODE_LINENO) > 0
515+
assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.httpx.test_httpx"
516+
assert data.get(SPANDATA.CODE_FILEPATH).endswith(
517+
"tests/integrations/httpx/test_httpx.py"
518+
)
519+
520+
is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
521+
assert is_relative_path
522+
523+
assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source"
524+
525+
526+
@pytest.mark.parametrize(
527+
"httpx_client",
528+
(httpx.Client(), httpx.AsyncClient()),
529+
)
530+
def test_request_source_with_module_in_search_path(
531+
sentry_init, capture_events, httpx_client, httpx_mock
532+
):
533+
"""
534+
Test that request source is relative to the path of the module it ran in
535+
"""
536+
httpx_mock.add_response()
537+
sentry_init(
538+
integrations=[HttpxIntegration()],
539+
traces_sample_rate=1.0,
540+
enable_http_request_source=True,
541+
http_request_source_threshold_ms=0,
542+
)
543+
544+
events = capture_events()
545+
546+
url = "http://example.com/"
547+
548+
with start_transaction(name="test_transaction"):
549+
if asyncio.iscoroutinefunction(httpx_client.get):
550+
from httpx_helpers.helpers import async_get_request_with_client
551+
552+
asyncio.get_event_loop().run_until_complete(
553+
async_get_request_with_client(httpx_client, url)
554+
)
555+
else:
556+
from httpx_helpers.helpers import get_request_with_client
557+
558+
get_request_with_client(httpx_client, url)
559+
560+
(event,) = events
561+
562+
span = event["spans"][-1]
563+
assert span["description"].startswith("GET")
564+
565+
data = span.get("data", {})
566+
567+
assert SPANDATA.CODE_LINENO in data
568+
assert SPANDATA.CODE_NAMESPACE in data
569+
assert SPANDATA.CODE_FILEPATH in data
570+
assert SPANDATA.CODE_FUNCTION in data
571+
572+
assert type(data.get(SPANDATA.CODE_LINENO)) == int
573+
assert data.get(SPANDATA.CODE_LINENO) > 0
574+
assert data.get(SPANDATA.CODE_NAMESPACE) == "httpx_helpers.helpers"
575+
assert data.get(SPANDATA.CODE_FILEPATH) == "httpx_helpers/helpers.py"
576+
577+
is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
578+
assert is_relative_path
579+
580+
if asyncio.iscoroutinefunction(httpx_client.get):
581+
assert data.get(SPANDATA.CODE_FUNCTION) == "async_get_request_with_client"
582+
else:
583+
assert data.get(SPANDATA.CODE_FUNCTION) == "get_request_with_client"
584+
585+
586+
@pytest.mark.parametrize(
587+
"httpx_client",
588+
(httpx.Client(), httpx.AsyncClient()),
589+
)
590+
def test_no_request_source_if_duration_too_short(
591+
sentry_init, capture_events, httpx_client, httpx_mock
592+
):
593+
httpx_mock.add_response()
594+
595+
sentry_init(
596+
integrations=[HttpxIntegration()],
597+
traces_sample_rate=1.0,
598+
enable_http_request_source=True,
599+
http_request_source_threshold_ms=100,
600+
)
601+
602+
events = capture_events()
603+
604+
url = "http://example.com/"
605+
606+
with start_transaction(name="test_transaction"):
607+
608+
@contextmanager
609+
def fake_start_span(*args, **kwargs):
610+
with sentry_sdk.start_span(*args, **kwargs) as span:
611+
pass
612+
span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
613+
span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999)
614+
yield span
615+
616+
with mock.patch(
617+
"sentry_sdk.integrations.httpx.start_span",
618+
fake_start_span,
619+
):
620+
if asyncio.iscoroutinefunction(httpx_client.get):
621+
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
622+
else:
623+
httpx_client.get(url)
624+
625+
(event,) = events
626+
627+
span = event["spans"][-1]
628+
assert span["description"].startswith("GET")
629+
630+
data = span.get("data", {})
631+
632+
assert SPANDATA.CODE_LINENO not in data
633+
assert SPANDATA.CODE_NAMESPACE not in data
634+
assert SPANDATA.CODE_FILEPATH not in data
635+
assert SPANDATA.CODE_FUNCTION not in data
636+
637+
638+
@pytest.mark.parametrize(
639+
"httpx_client",
640+
(httpx.Client(), httpx.AsyncClient()),
641+
)
642+
def test_request_source_if_duration_over_threshold(
643+
sentry_init, capture_events, httpx_client, httpx_mock
644+
):
645+
httpx_mock.add_response()
646+
647+
sentry_init(
648+
integrations=[HttpxIntegration()],
649+
traces_sample_rate=1.0,
650+
enable_db_query_source=True,
651+
db_query_source_threshold_ms=100,
652+
)
653+
654+
events = capture_events()
655+
656+
url = "http://example.com/"
657+
658+
with start_transaction(name="test_transaction"):
659+
660+
@contextmanager
661+
def fake_start_span(*args, **kwargs):
662+
with sentry_sdk.start_span(*args, **kwargs) as span:
663+
pass
664+
span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
665+
span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001)
666+
yield span
667+
668+
with mock.patch(
669+
"sentry_sdk.integrations.httpx.start_span",
670+
fake_start_span,
671+
):
672+
if asyncio.iscoroutinefunction(httpx_client.get):
673+
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
674+
else:
675+
httpx_client.get(url)
676+
677+
(event,) = events
678+
679+
span = event["spans"][-1]
680+
assert span["description"].startswith("GET")
681+
682+
data = span.get("data", {})
683+
684+
assert SPANDATA.CODE_LINENO in data
685+
assert SPANDATA.CODE_NAMESPACE in data
686+
assert SPANDATA.CODE_FILEPATH in data
687+
assert SPANDATA.CODE_FUNCTION in data
688+
689+
assert type(data.get(SPANDATA.CODE_LINENO)) == int
690+
assert data.get(SPANDATA.CODE_LINENO) > 0
691+
assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.httpx.test_httpx"
692+
assert data.get(SPANDATA.CODE_FILEPATH).endswith(
693+
"tests/integrations/httpx/test_httpx.py"
694+
)
695+
696+
is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
697+
assert is_relative_path
698+
699+
assert (
700+
data.get(SPANDATA.CODE_FUNCTION)
701+
== "test_query_source_if_duration_over_threshold"
702+
)
703+
704+
396705
@pytest.mark.parametrize(
397706
"httpx_client",
398707
(httpx.Client(), httpx.AsyncClient()),

0 commit comments

Comments
 (0)