Skip to content

Commit 4639c12

Browse files
committed
feat(integrations): instrument pyreqwest tracing
1 parent 6ea663f commit 4639c12

File tree

9 files changed

+389
-21
lines changed

9 files changed

+389
-21
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: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,13 @@
292292
},
293293
"python": ">=3.10",
294294
},
295+
"pydantic_ai": {
296+
"package": "pydantic-ai",
297+
"deps": {
298+
"*": ["pytest-asyncio"],
299+
},
300+
"python": ">=3.10",
301+
},
295302
"openfeature": {
296303
"package": "openfeature-sdk",
297304
"num_versions": 2,
@@ -300,11 +307,12 @@
300307
"package": "pure_eval",
301308
"num_versions": 2,
302309
},
303-
"pydantic_ai": {
304-
"package": "pydantic-ai",
310+
"pyreqwest": {
311+
"package": "pyreqwest",
305312
"deps": {
306313
"*": ["pytest-asyncio"],
307314
},
315+
"python": ">=3.11",
308316
},
309317
"pymongo": {
310318
"package": "pymongo",

scripts/populate_tox/package_dependencies.jsonl

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

scripts/populate_tox/releases.jsonl

Lines changed: 7 additions & 5 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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def iter_default_integrations(
9494
"sentry_sdk.integrations.openai.OpenAIIntegration",
9595
"sentry_sdk.integrations.openai_agents.OpenAIAgentsIntegration",
9696
"sentry_sdk.integrations.pydantic_ai.PydanticAIIntegration",
97+
"sentry_sdk.integrations.pyreqwest.PyreqwestIntegration",
9798
"sentry_sdk.integrations.pymongo.PyMongoIntegration",
9899
"sentry_sdk.integrations.pyramid.PyramidIntegration",
99100
"sentry_sdk.integrations.quart.QuartIntegration",
@@ -150,6 +151,7 @@ def iter_default_integrations(
150151
"openfeature": (0, 7, 1),
151152
"pydantic_ai": (1, 0, 0),
152153
"pymongo": (3, 5, 0),
154+
"pyreqwest": (0, 11, 6),
153155
"quart": (0, 16, 0),
154156
"ray": (2, 7, 0),
155157
"requests": (2, 0, 0),
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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 typing import TYPE_CHECKING
19+
20+
if TYPE_CHECKING:
21+
from typing import Any
22+
23+
24+
import importlib.util
25+
26+
if importlib.util.find_spec("pyreqwest") is None:
27+
raise DidNotEnable("pyreqwest is not installed")
28+
29+
30+
class PyreqwestIntegration(Integration):
31+
identifier = "pyreqwest"
32+
origin = f"auto.http.{identifier}"
33+
34+
@staticmethod
35+
def setup_once() -> None:
36+
_patch_pyreqwest()
37+
38+
39+
def _patch_pyreqwest() -> None:
40+
# Patch Client Builders
41+
try:
42+
from pyreqwest.client import ClientBuilder, SyncClientBuilder # type: ignore[import-not-found]
43+
44+
_patch_builder_method(ClientBuilder, "build", sentry_async_middleware)
45+
_patch_builder_method(SyncClientBuilder, "build", sentry_sync_middleware)
46+
except ImportError:
47+
pass
48+
49+
# Patch Request Builders (for simple requests and manual request building)
50+
try:
51+
from pyreqwest.request import ( # type: ignore[import-not-found]
52+
RequestBuilder,
53+
SyncRequestBuilder,
54+
OneOffRequestBuilder,
55+
SyncOneOffRequestBuilder,
56+
)
57+
58+
_patch_builder_method(RequestBuilder, "build", sentry_async_middleware)
59+
_patch_builder_method(RequestBuilder, "build_streamed", sentry_async_middleware)
60+
_patch_builder_method(SyncRequestBuilder, "build", sentry_sync_middleware)
61+
_patch_builder_method(
62+
SyncRequestBuilder, "build_streamed", sentry_sync_middleware
63+
)
64+
_patch_builder_method(OneOffRequestBuilder, "send", sentry_async_middleware)
65+
_patch_builder_method(SyncOneOffRequestBuilder, "send", sentry_sync_middleware)
66+
except ImportError:
67+
pass
68+
69+
70+
def _patch_builder_method(cls: type, method_name: str, middleware: "Any") -> None:
71+
if not hasattr(cls, method_name):
72+
return
73+
74+
original_method = getattr(cls, method_name)
75+
76+
def sentry_patched_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
77+
if not getattr(self, "_sentry_instrumented", False):
78+
integration = sentry_sdk.get_client().get_integration(PyreqwestIntegration)
79+
if integration is not None:
80+
self.with_middleware(middleware)
81+
try:
82+
self._sentry_instrumented = True
83+
except (TypeError, AttributeError):
84+
# In case the instance itself is immutable or doesn't allow extra attributes
85+
pass
86+
return original_method(self, *args, **kwargs)
87+
88+
setattr(cls, method_name, sentry_patched_method)
89+
90+
91+
async def sentry_async_middleware(request: "Any", next_handler: "Any") -> "Any":
92+
if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None:
93+
return await next_handler.run(request)
94+
95+
parsed_url = None
96+
with capture_internal_exceptions():
97+
parsed_url = parse_url(str(request.url), sanitize=False)
98+
99+
with start_span(
100+
op=OP.HTTP_CLIENT,
101+
name="%s %s"
102+
% (
103+
request.method,
104+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
105+
),
106+
origin=PyreqwestIntegration.origin,
107+
) as span:
108+
span.set_data(SPANDATA.HTTP_METHOD, request.method)
109+
if parsed_url is not None:
110+
span.set_data("url", parsed_url.url)
111+
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
112+
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
113+
114+
if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
115+
for (
116+
key,
117+
value,
118+
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
119+
logger.debug(
120+
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
121+
key=key, value=value, url=request.url
122+
)
123+
)
124+
125+
if key == BAGGAGE_HEADER_NAME:
126+
add_sentry_baggage_to_headers(request.headers, value)
127+
else:
128+
request.headers[key] = value
129+
130+
response = await next_handler.run(request)
131+
132+
span.set_http_status(response.status)
133+
134+
with capture_internal_exceptions():
135+
add_http_request_source(span)
136+
137+
return response
138+
139+
140+
def sentry_sync_middleware(request: "Any", next_handler: "Any") -> "Any":
141+
if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None:
142+
return next_handler.run(request)
143+
144+
parsed_url = None
145+
with capture_internal_exceptions():
146+
parsed_url = parse_url(str(request.url), sanitize=False)
147+
148+
with start_span(
149+
op=OP.HTTP_CLIENT,
150+
name="%s %s"
151+
% (
152+
request.method,
153+
parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE,
154+
),
155+
origin=PyreqwestIntegration.origin,
156+
) as span:
157+
span.set_data(SPANDATA.HTTP_METHOD, request.method)
158+
if parsed_url is not None:
159+
span.set_data("url", parsed_url.url)
160+
span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query)
161+
span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment)
162+
163+
if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
164+
for (
165+
key,
166+
value,
167+
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
168+
logger.debug(
169+
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
170+
key=key, value=value, url=request.url
171+
)
172+
)
173+
174+
if key == BAGGAGE_HEADER_NAME:
175+
add_sentry_baggage_to_headers(request.headers, value)
176+
else:
177+
request.headers[key] = value
178+
179+
response = next_handler.run(request)
180+
181+
span.set_http_status(response.status)
182+
183+
with capture_internal_exceptions():
184+
add_http_request_source(span)
185+
186+
return response

0 commit comments

Comments
 (0)