Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test-integrations-network.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ jobs:
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-httpx"
- name: Test pyreqwest
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-pyreqwest"
- name: Test requests
run: |
set -x # print commands that are executed
Expand Down
8 changes: 8 additions & 0 deletions scripts/populate_tox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,14 @@
"deps": {
"*": ["pytest-asyncio"],
},
"python": ">=3.10",
},
"pyreqwest": {
"package": "pyreqwest",
"deps": {
"*": ["pytest-asyncio"],
},
"python": ">=3.11",
},
"pymongo": {
"package": "pymongo",
Expand Down
11 changes: 6 additions & 5 deletions scripts/populate_tox/package_dependencies.jsonl

Large diffs are not rendered by default.

18 changes: 8 additions & 10 deletions scripts/populate_tox/releases.jsonl

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions scripts/split_tox_gh_actions/split_tox_gh_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"Network": [
"grpc",
"httpx",
"pyreqwest",
"requests",
],
"Tasks": [
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def iter_default_integrations(
"openfeature": (0, 7, 1),
"pydantic_ai": (1, 0, 0),
"pymongo": (3, 5, 0),
"pyreqwest": (0, 11, 6),
"quart": (0, 16, 0),
"ray": (2, 7, 0),
"requests": (2, 0, 0),
Expand Down
136 changes: 136 additions & 0 deletions sentry_sdk/integrations/pyreqwest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import sentry_sdk
from sentry_sdk import start_span
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME
from sentry_sdk.tracing_utils import (
should_propagate_trace,
add_http_request_source,
add_sentry_baggage_to_headers,
)
from sentry_sdk.utils import (
SENSITIVE_DATA_SUBSTITUTE,
capture_internal_exceptions,
logger,
parse_url,
)

from contextlib import contextmanager
from typing import Any, Generator

try:
from pyreqwest.client import ClientBuilder, SyncClientBuilder # type: ignore[import-not-found]
from pyreqwest.request import ( # type: ignore[import-not-found]
Request,
OneOffRequestBuilder,
SyncOneOffRequestBuilder,
)
from pyreqwest.middleware import Next, SyncNext # type: ignore[import-not-found]
from pyreqwest.response import Response, SyncResponse # type: ignore[import-not-found]
except ImportError:
raise DidNotEnable("pyreqwest not installed or incompatible version installed")


class PyreqwestIntegration(Integration):
identifier = "pyreqwest"
origin = f"auto.http.{identifier}"

@staticmethod
def setup_once() -> None:
_patch_pyreqwest()


def _patch_pyreqwest() -> None:
# Patch Client Builders
_patch_builder_method(ClientBuilder, "build", sentry_async_middleware)
_patch_builder_method(SyncClientBuilder, "build", sentry_sync_middleware)

# Patch Request Builders
_patch_builder_method(OneOffRequestBuilder, "send", sentry_async_middleware)
_patch_builder_method(SyncOneOffRequestBuilder, "send", sentry_sync_middleware)


def _patch_builder_method(cls: type, method_name: str, middleware: "Any") -> None:
if not hasattr(cls, method_name):
return

original_method = getattr(cls, method_name)

def sentry_patched_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
if not getattr(self, "_sentry_instrumented", False):
integration = sentry_sdk.get_client().get_integration(PyreqwestIntegration)
if integration is not None:
self.with_middleware(middleware)
try:
self._sentry_instrumented = True
Copy link
Copy Markdown

@MarkusSintonen MarkusSintonen Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we have some test to check if this works and doesn't always give the exception? (as these are native classes)

except (TypeError, AttributeError):
# In case the instance itself is immutable or doesn't allow extra attributes
pass
return original_method(self, *args, **kwargs)

setattr(cls, method_name, sentry_patched_method)


@contextmanager
def _sentry_pyreqwest_span(request: "Request") -> "Generator[Any, None, None]":
parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)

with start_span(
op=OP.HTTP_CLIENT,
name=f"{request.method} {parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE}",
origin=PyreqwestIntegration.origin,
) as span:
span.set_data(SPANDATA.HTTP_METHOD, request.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)

if should_propagate_trace(sentry_sdk.get_client(), str(request.url)):
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers():
logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {url}.".format(
key=key, value=value, url=request.url
)
)

if key == BAGGAGE_HEADER_NAME:
add_sentry_baggage_to_headers(request.headers, value)
else:
request.headers[key] = value

yield span

with capture_internal_exceptions():
add_http_request_source(span)


async def sentry_async_middleware(
request: "Request", next_handler: "Next"
) -> "Response":
if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None:
return await next_handler.run(request)

with _sentry_pyreqwest_span(request) as span:
response = await next_handler.run(request)
span.set_http_status(response.status)

return response


def sentry_sync_middleware(
request: "Request", next_handler: "SyncNext"
) -> "SyncResponse":
if sentry_sdk.get_client().get_integration(PyreqwestIntegration) is None:
return next_handler.run(request)

with _sentry_pyreqwest_span(request) as span:
response = next_handler.run(request)
span.set_http_status(response.status)

return response
3 changes: 3 additions & 0 deletions tests/integrations/pyreqwest/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytest.importorskip("pyreqwest")
152 changes: 152 additions & 0 deletions tests/integrations/pyreqwest/test_pyreqwest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
import pytest

from pyreqwest.client import ClientBuilder, SyncClientBuilder
from pyreqwest.simple.request import pyreqwest_get as async_pyreqwest_get
from pyreqwest.simple.sync_request import pyreqwest_get as sync_pyreqwest_get

from sentry_sdk import start_transaction
from sentry_sdk.consts import SPANDATA
from sentry_sdk.integrations.pyreqwest import PyreqwestIntegration
from tests.conftest import get_free_port


class PyreqwestMockHandler(BaseHTTPRequestHandler):
captured_requests = []

def do_GET(self) -> None:
self.captured_requests.append(
{
"path": self.path,
"headers": {k.lower(): v for k, v in self.headers.items()},
}
)

code = 200
if "/status/" in self.path:
try:
code = int(self.path.split("/")[-1])
except (ValueError, IndexError):
code = 200

self.send_response(code)
self.end_headers()
self.wfile.write(b"OK")

def log_message(self, format: str, *args: object) -> None:
pass


@pytest.fixture(scope="module")
def server_port():
port = get_free_port()
server = HTTPServer(("localhost", port), PyreqwestMockHandler)
thread = Thread(target=server.serve_forever)
thread.daemon = True
thread.start()
yield port
server.shutdown()


@pytest.fixture(autouse=True)
def clear_captured_requests():
PyreqwestMockHandler.captured_requests.clear()


def test_sync_client_spans(sentry_init, capture_events, server_port):
sentry_init(integrations=[PyreqwestIntegration()], traces_sample_rate=1.0)
events = capture_events()

url = f"http://localhost:{server_port}/hello"
with start_transaction(name="test_transaction"):
client = SyncClientBuilder().build()
response = client.get(url).build().send()
assert response.status == 200

(event,) = events
assert len(event["spans"]) == 1
span = event["spans"][0]
assert span["op"] == "http.client"
assert span["description"] == f"GET {url}"
assert span["data"]["url"] == url
assert span["data"][SPANDATA.HTTP_STATUS_CODE] == 200
assert span["origin"] == "auto.http.pyreqwest"


@pytest.mark.asyncio
async def test_async_client_spans(sentry_init, capture_events, server_port):
sentry_init(integrations=[PyreqwestIntegration()], traces_sample_rate=1.0)
events = capture_events()

url = f"http://localhost:{server_port}/hello"
async with ClientBuilder().build() as client:
with start_transaction(name="test_transaction"):
response = await client.get(url).build().send()
assert response.status == 200

(event,) = events
assert len(event["spans"]) == 1
span = event["spans"][0]
assert span["op"] == "http.client"
assert span["description"] == f"GET {url}"
assert span["data"]["url"] == url
assert span["data"][SPANDATA.HTTP_STATUS_CODE] == 200
assert span["origin"] == "auto.http.pyreqwest"


def test_sync_simple_request_spans(sentry_init, capture_events, server_port):
sentry_init(integrations=[PyreqwestIntegration()], traces_sample_rate=1.0)
events = capture_events()

url = f"http://localhost:{server_port}/hello-simple"
with start_transaction(name="test_transaction"):
response = sync_pyreqwest_get(url).send()
assert response.status == 200

(event,) = events
assert len(event["spans"]) == 1
span = event["spans"][0]
assert span["op"] == "http.client"
assert span["description"] == f"GET {url}"


@pytest.mark.asyncio
async def test_async_simple_request_spans(sentry_init, capture_events, server_port):
sentry_init(integrations=[PyreqwestIntegration()], traces_sample_rate=1.0)
events = capture_events()

url = f"http://localhost:{server_port}/hello-simple-async"
with start_transaction(name="test_transaction"):
response = await async_pyreqwest_get(url).send()
assert response.status == 200

(event,) = events
assert len(event["spans"]) == 1
span = event["spans"][0]
assert span["op"] == "http.client"
assert span["description"] == f"GET {url}"


def test_outgoing_trace_headers(sentry_init, server_port):
sentry_init(
integrations=[PyreqwestIntegration()],
traces_sample_rate=1.0,
trace_propagation_targets=["localhost"],
)

url = f"http://localhost:{server_port}/trace"
with start_transaction(
name="test_transaction", trace_id="01234567890123456789012345678901"
):
client = SyncClientBuilder().build()
response = client.get(url).build().send()
assert response.status == 200

assert len(PyreqwestMockHandler.captured_requests) == 1
headers = PyreqwestMockHandler.captured_requests[0]["headers"]

assert "sentry-trace" in headers
assert headers["sentry-trace"].startswith("01234567890123456789012345678901")
assert "baggage" in headers
assert "sentry-trace_id=01234567890123456789012345678901" in headers["baggage"]
Loading
Loading