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
12 changes: 10 additions & 2 deletions scripts/populate_tox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,13 @@
},
"python": ">=3.10",
},
"pydantic_ai": {
"package": "pydantic-ai",
"deps": {
"*": ["pytest-asyncio"],
},
"python": ">=3.10",
},
"openfeature": {
"package": "openfeature-sdk",
"num_versions": 2,
Expand All @@ -300,11 +307,12 @@
"package": "pure_eval",
"num_versions": 2,
},
"pydantic_ai": {
"package": "pydantic-ai",
"pyreqwest": {
"package": "pyreqwest",
"deps": {
"*": ["pytest-asyncio"],
},
"python": ">=3.11",
},
"pymongo": {
"package": "pymongo",
Expand Down
8 changes: 4 additions & 4 deletions scripts/populate_tox/package_dependencies.jsonl

Large diffs are not rendered by default.

12 changes: 7 additions & 5 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 @@ -124,6 +124,7 @@
"Network": [
"grpc",
"httpx",
"pyreqwest",
"requests",
],
"Tasks": [
Expand Down
2 changes: 2 additions & 0 deletions sentry_sdk/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def iter_default_integrations(
"sentry_sdk.integrations.openai.OpenAIIntegration",
"sentry_sdk.integrations.openai_agents.OpenAIAgentsIntegration",
"sentry_sdk.integrations.pydantic_ai.PydanticAIIntegration",
"sentry_sdk.integrations.pyreqwest.PyreqwestIntegration",
"sentry_sdk.integrations.pymongo.PyMongoIntegration",
"sentry_sdk.integrations.pyramid.PyramidIntegration",
"sentry_sdk.integrations.quart.QuartIntegration",
Expand Down Expand Up @@ -150,6 +151,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
186 changes: 186 additions & 0 deletions sentry_sdk/integrations/pyreqwest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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 typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Any


import importlib.util

if importlib.util.find_spec("pyreqwest") is None:
raise DidNotEnable("pyreqwest is not 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
try:
from pyreqwest.client import ClientBuilder, SyncClientBuilder # type: ignore[import-not-found]

_patch_builder_method(ClientBuilder, "build", sentry_async_middleware)
_patch_builder_method(SyncClientBuilder, "build", sentry_sync_middleware)
except ImportError:
pass

# Patch Request Builders (for simple requests and manual request building)
try:
from pyreqwest.request import ( # type: ignore[import-not-found]
RequestBuilder,
SyncRequestBuilder,
OneOffRequestBuilder,
SyncOneOffRequestBuilder,
)

_patch_builder_method(RequestBuilder, "build", sentry_async_middleware)
_patch_builder_method(RequestBuilder, "build_streamed", sentry_async_middleware)
_patch_builder_method(SyncRequestBuilder, "build", sentry_sync_middleware)
_patch_builder_method(
SyncRequestBuilder, "build_streamed", sentry_sync_middleware
)
_patch_builder_method(OneOffRequestBuilder, "send", sentry_async_middleware)
_patch_builder_method(SyncOneOffRequestBuilder, "send", sentry_sync_middleware)
except ImportError:
pass


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
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)


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

parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)

with start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
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

response = await next_handler.run(request)

span.set_http_status(response.status)

with capture_internal_exceptions():
add_http_request_source(span)

return response


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

parsed_url = None
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)

with start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
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

response = next_handler.run(request)

span.set_http_status(response.status)

with capture_internal_exceptions():
add_http_request_source(span)

return response
Loading
Loading