Skip to content

Commit f3c7366

Browse files
feat(integrations): instrument pyreqwest tracing (#5682)
### Description Implements request tracing for the [`pyreqwest`](https://github.com/MarkusSintonen/pyreqwest) HTTP library. Includes tests and CI updates. #### Issues Resolves MarkusSintonen/pyreqwest#30. --------- Co-authored-by: Ivana Kellyer <ivana.kellyer@sentry.io>
1 parent 0b3e919 commit f3c7366

File tree

12 files changed

+693
-39
lines changed

12 files changed

+693
-39
lines changed

.github/workflows/test-integrations-network.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ jobs:
6161
run: |
6262
set -x # print commands that are executed
6363
./scripts/runtox.sh "py${{ matrix.python-version }}-httpx"
64+
- name: Test pyreqwest
65+
run: |
66+
set -x # print commands that are executed
67+
./scripts/runtox.sh "py${{ matrix.python-version }}-pyreqwest"
6468
- name: Test requests
6569
run: |
6670
set -x # print commands that are executed

scripts/populate_tox/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,14 @@
308308
"deps": {
309309
"*": ["pytest-asyncio"],
310310
},
311+
"python": ">=3.10",
312+
},
313+
"pyreqwest": {
314+
"package": "pyreqwest",
315+
"deps": {
316+
"*": ["pytest-asyncio"],
317+
},
318+
"python": ">=3.11",
311319
},
312320
"pymongo": {
313321
"package": "pymongo",

scripts/populate_tox/package_dependencies.jsonl

Lines changed: 8 additions & 7 deletions
Large diffs are not rendered by default.

scripts/populate_tox/releases.jsonl

Lines changed: 11 additions & 9 deletions
Large diffs are not rendered by default.

scripts/split_tox_gh_actions/split_tox_gh_actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"Network": [
125125
"grpc",
126126
"httpx",
127+
"pyreqwest",
127128
"requests",
128129
],
129130
"Tasks": [

sentry_sdk/integrations/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def iter_default_integrations(
150150
"openfeature": (0, 7, 1),
151151
"pydantic_ai": (1, 0, 0),
152152
"pymongo": (3, 5, 0),
153+
"pyreqwest": (0, 11, 6),
153154
"quart": (0, 16, 0),
154155
"ray": (2, 7, 0),
155156
"requests": (2, 0, 0),
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import sentry_sdk
2+
from sentry_sdk import start_span
3+
from sentry_sdk.consts import OP, SPANDATA
4+
from sentry_sdk.integrations import Integration, DidNotEnable
5+
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
6+
from sentry_sdk.tracing_utils import (
7+
should_propagate_trace,
8+
add_http_request_source,
9+
add_sentry_baggage_to_headers,
10+
)
11+
from sentry_sdk.utils import (
12+
SENSITIVE_DATA_SUBSTITUTE,
13+
capture_internal_exceptions,
14+
logger,
15+
parse_url,
16+
)
17+
18+
from contextlib import contextmanager
19+
from typing import Any, Generator
20+
21+
try:
22+
from pyreqwest.client import ClientBuilder, SyncClientBuilder # type: ignore[import-not-found]
23+
from pyreqwest.request import ( # type: ignore[import-not-found]
24+
Request,
25+
OneOffRequestBuilder,
26+
SyncOneOffRequestBuilder,
27+
)
28+
from pyreqwest.middleware import Next, SyncNext # type: ignore[import-not-found]
29+
from pyreqwest.response import Response, SyncResponse # type: ignore[import-not-found]
30+
except ImportError:
31+
raise DidNotEnable("pyreqwest not installed or incompatible version installed")
32+
33+
34+
class PyreqwestIntegration(Integration):
35+
identifier = "pyreqwest"
36+
origin = f"auto.http.{identifier}"
37+
38+
@staticmethod
39+
def setup_once() -> None:
40+
_patch_pyreqwest()
41+
42+
43+
def _patch_pyreqwest() -> None:
44+
# Patch Client Builders
45+
_patch_builder_method(ClientBuilder, "build", sentry_async_middleware)
46+
_patch_builder_method(SyncClientBuilder, "build", sentry_sync_middleware)
47+
48+
# Patch Request Builders
49+
_patch_builder_method(OneOffRequestBuilder, "send", sentry_async_middleware)
50+
_patch_builder_method(SyncOneOffRequestBuilder, "send", sentry_sync_middleware)
51+
52+
53+
def _patch_builder_method(cls: type, method_name: str, middleware: "Any") -> None:
54+
if not hasattr(cls, method_name):
55+
return
56+
57+
original_method = getattr(cls, method_name)
58+
59+
def sentry_patched_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
60+
if not getattr(self, "_sentry_instrumented", False):
61+
integration = sentry_sdk.get_client().get_integration(PyreqwestIntegration)
62+
if integration is not None:
63+
self.with_middleware(middleware)
64+
try:
65+
self._sentry_instrumented = True
66+
except (TypeError, AttributeError):
67+
# In case the instance itself is immutable or doesn't allow extra attributes
68+
pass
69+
return original_method(self, *args, **kwargs)
70+
71+
setattr(cls, method_name, sentry_patched_method)
72+
73+
74+
@contextmanager
75+
def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]":
76+
parsed_url = None
77+
with capture_internal_exceptions():
78+
parsed_url = parse_url(str(request.url), sanitize=False)
79+
80+
with start_span(
81+
op=OP.HTTP_CLIENT,
82+
name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}",
83+
origin=PyreqwestIntegration.origin,
84+
) as span:
85+
span.set_data(SPANDATA.HTTP_METHOD, request.method)
86+
if parsed_url is not None:
87+
span.set_data("url", parsed_url.url)
88+
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
89+
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
90+
91+
if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
92+
for (
93+
key,
94+
value,
95+
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
96+
logger.debug(
97+
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
98+
key=key, value=value, url=request.url
99+
)
100+
)
101+
102+
if key == BAGGAGE_HEADER_NAME:
103+
add_sentry_baggage_to_headers(request.headers, value)
104+
else:
105+
request.headers[key] = value
106+
107+
yield span
108+
109+
with capture_internal_exceptions():
110+
add_http_request_source(span)
111+
112+
113+
async def sentry_async_middleware(
114+
request: "Request", next_handler: "Next"
115+
) -> "Response":
116+
if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None:
117+
return await next_handler.run(request)
118+
119+
with _sentry_pyreqwest_span(request) as span:
120+
response = await next_handler.run(request)
121+
span.set_http_status(response.status)
122+
123+
return response
124+
125+
126+
def sentry_sync_middleware(
127+
request: "Request", next_handler: "SyncNext"
128+
) -> "SyncResponse":
129+
if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None:
130+
return next_handler.run(request)
131+
132+
with _sentry_pyreqwest_span(request) as span:
133+
response = next_handler.run(request)
134+
span.set_http_status(response.status)
135+
136+
return response
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import os
2+
import sys
3+
import pytest
4+
5+
pytest.importorskip("pyreqwest")
6+
7+
# Load `pyreqwest_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/pyreqwest/pyreqwest_helpers/__init__.py

Whitespace-only changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def get_request_with_client(client, url):
2+
client.get(url).build().send()

0 commit comments

Comments
 (0)