Repository: getsentry/sentry-python

Issue:
integration for httpx
<!--
Please provide some context on what problem you are trying to solve and how this feature is going to help.

-->

About [httpx](https://github.com/encode/httpx/):

> HTTPX is a fully featured HTTP client for Python 3, which provides sync and async APIs, and support for both HTTP/1.1 and HTTP/2.

About integration:

Currently, sentry has integration for `httplib` but httpx has its own transport layer and [we can customize the transport layer if we needed](https://www.python-httpx.org/advanced/#custom-transports). 

About implementation:

We can patch `Client.send` and `AsyncClient.send` to support both synchronous and async interfaces.

I already write this integration for internal use, I will make a pr if you want to accept this feature.


Discussion:

Resolving patch:
diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py
new file mode 100644
--- /dev/null
+++ b/sentry_sdk/integrations/httpx.py
@@ -0,0 +1,83 @@
+from sentry_sdk import Hub
+from sentry_sdk.integrations import Integration, DidNotEnable
+
+from sentry_sdk._types import MYPY
+
+if MYPY:
+    from typing import Any
+
+
+try:
+    from httpx import AsyncClient, Client, Request, Response  # type: ignore
+except ImportError:
+    raise DidNotEnable("httpx is not installed")
+
+__all__ = ["HttpxIntegration"]
+
+
+class HttpxIntegration(Integration):
+    identifier = "httpx"
+
+    @staticmethod
+    def setup_once():
+        # type: () -> None
+        """
+        httpx has its own transport layer and can be customized when needed,
+        so patch Client.send and AsyncClient.send to support both synchronous and async interfaces.
+        """
+        _install_httpx_client()
+        _install_httpx_async_client()
+
+
+def _install_httpx_client():
+    # type: () -> None
+    real_send = Client.send
+
+    def send(self, request, **kwargs):
+        # type: (Client, Request, **Any) -> Response
+        hub = Hub.current
+        if hub.get_integration(HttpxIntegration) is None:
+            return real_send(self, request, **kwargs)
+
+        with hub.start_span(
+            op="http", description="%s %s" % (request.method, request.url)
+        ) as span:
+            span.set_data("method", request.method)
+            span.set_data("url", str(request.url))
+            for key, value in hub.iter_trace_propagation_headers():
+                request.headers[key] = value
+            rv = real_send(self, request, **kwargs)
+
+            span.set_data("status_code", rv.status_code)
+            span.set_http_status(rv.status_code)
+            span.set_data("reason", rv.reason_phrase)
+            return rv
+
+    Client.send = send
+
+
+def _install_httpx_async_client():
+    # type: () -> None
+    real_send = AsyncClient.send
+
+    async def send(self, request, **kwargs):
+        # type: (AsyncClient, Request, **Any) -> Response
+        hub = Hub.current
+        if hub.get_integration(HttpxIntegration) is None:
+            return await real_send(self, request, **kwargs)
+
+        with hub.start_span(
+            op="http", description="%s %s" % (request.method, request.url)
+        ) as span:
+            span.set_data("method", request.method)
+            span.set_data("url", str(request.url))
+            for key, value in hub.iter_trace_propagation_headers():
+                request.headers[key] = value
+            rv = await real_send(self, request, **kwargs)
+
+            span.set_data("status_code", rv.status_code)
+            span.set_http_status(rv.status_code)
+            span.set_data("reason", rv.reason_phrase)
+            return rv
+
+    AsyncClient.send = send
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -53,6 +53,7 @@ def get_file_text(file_name):
         "pyspark": ["pyspark>=2.4.4"],
         "pure_eval": ["pure_eval", "executing", "asttokens"],
         "chalice": ["chalice>=1.16.0"],
+        "httpx": ["httpx>=0.16.0"],
     },
     classifiers=[
         "Development Status :: 5 - Production/Stable",


Test patch:
diff --git a/tests/integrations/httpx/__init__.py b/tests/integrations/httpx/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/integrations/httpx/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pytest.importorskip("httpx")
diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py
new file mode 100644
--- /dev/null
+++ b/tests/integrations/httpx/test_httpx.py
@@ -0,0 +1,66 @@
+import asyncio
+
+import httpx
+
+from sentry_sdk import capture_message, start_transaction
+from sentry_sdk.integrations.httpx import HttpxIntegration
+
+
+def test_crumb_capture_and_hint(sentry_init, capture_events):
+    def before_breadcrumb(crumb, hint):
+        crumb["data"]["extra"] = "foo"
+        return crumb
+
+    sentry_init(integrations=[HttpxIntegration()], before_breadcrumb=before_breadcrumb)
+    clients = (httpx.Client(), httpx.AsyncClient())
+    for i, c in enumerate(clients):
+        with start_transaction():
+            events = capture_events()
+
+            url = "https://httpbin.org/status/200"
+            if not asyncio.iscoroutinefunction(c.get):
+                response = c.get(url)
+            else:
+                response = asyncio.get_event_loop().run_until_complete(c.get(url))
+
+            assert response.status_code == 200
+            capture_message("Testing!")
+
+            (event,) = events
+            # send request twice so we need get breadcrumb by index
+            crumb = event["breadcrumbs"]["values"][i]
+            assert crumb["type"] == "http"
+            assert crumb["category"] == "httplib"
+            assert crumb["data"] == {
+                "url": url,
+                "method": "GET",
+                "status_code": 200,
+                "reason": "OK",
+                "extra": "foo",
+            }
+
+
+def test_outgoing_trace_headers(sentry_init):
+    sentry_init(traces_sample_rate=1.0, integrations=[HttpxIntegration()])
+    clients = (httpx.Client(), httpx.AsyncClient())
+    for i, c in enumerate(clients):
+        with start_transaction(
+            name="/interactions/other-dogs/new-dog",
+            op="greeting.sniff",
+            # make trace_id difference between transactions
+            trace_id=f"012345678901234567890123456789{i}",
+        ) as transaction:
+            url = "https://httpbin.org/status/200"
+            if not asyncio.iscoroutinefunction(c.get):
+                response = c.get(url)
+            else:
+                response = asyncio.get_event_loop().run_until_complete(c.get(url))
+
+            request_span = transaction._span_recorder.spans[-1]
+            assert response.request.headers[
+                "sentry-trace"
+            ] == "{trace_id}-{parent_span_id}-{sampled}".format(
+                trace_id=transaction.trace_id,
+                parent_span_id=request_span.span_id,
+                sampled=1,
+            )