Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
21 changes: 16 additions & 5 deletions sentry_sdk/integrations/httpx.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
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 Baggage, should_propagate_trace
from sentry_sdk.tracing_utils import (
Baggage,
should_propagate_trace,
add_http_request_source,
)
from sentry_sdk.utils import (
SENSITIVE_DATA_SUBSTITUTE,
capture_internal_exceptions,
Expand Down Expand Up @@ -52,7 +57,7 @@ def send(self, request, **kwargs):
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)

with sentry_sdk.start_span(
with start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
Expand Down Expand Up @@ -88,7 +93,10 @@ def send(self, request, **kwargs):
span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)

return rv
with capture_internal_exceptions():
add_http_request_source(span)
Copy link

Choose a reason for hiding this comment

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

Bug: Span Context Mismatch Causes Missing Data

The add_http_request_source(span) call is placed outside the span context manager, which means it executes after the span has finished. This prevents the HTTP request source information from being correctly added to the span.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

@alexander-alderman-webb alexander-alderman-webb Oct 9, 2025

Choose a reason for hiding this comment

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

I placed add_http_request_source(span) after the span is finished because you need the end timestamp to determine the delay in receiving a response to the HTTP request.

It's done analogously in asyncpg and sqlalchemy.


return rv

Client.send = send

Expand All @@ -106,7 +114,7 @@ async def send(self, request, **kwargs):
with capture_internal_exceptions():
parsed_url = parse_url(str(request.url), sanitize=False)

with sentry_sdk.start_span(
with start_span(
op=OP.HTTP_CLIENT,
name="%s %s"
% (
Expand Down Expand Up @@ -144,7 +152,10 @@ async def send(self, request, **kwargs):
span.set_http_status(rv.status_code)
span.set_data("reason", rv.reason_phrase)

return rv
with capture_internal_exceptions():
add_http_request_source(span)

return rv

AsyncClient.send = send

Expand Down
6 changes: 6 additions & 0 deletions tests/integrations/httpx/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import os
import sys
import pytest

pytest.importorskip("httpx")

# Load `httpx_helpers` into the module search path to test request source path names relative to module. See
# `test_request_source_with_module_in_search_path`
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
Empty file.
6 changes: 6 additions & 0 deletions tests/integrations/httpx/httpx_helpers/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def get_request_with_client(client, url):
client.get(url)


async def async_get_request_with_client(client, url):
await client.get(url)
309 changes: 309 additions & 0 deletions tests/integrations/httpx/test_httpx.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import os
import datetime
import asyncio
from unittest import mock

import httpx
import pytest
from contextlib import contextmanager

import sentry_sdk
from sentry_sdk import capture_message, start_transaction
Expand Down Expand Up @@ -393,6 +396,312 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events, httpx_mock)
assert SPANDATA.HTTP_QUERY not in event["breadcrumbs"]["values"][0]["data"]


@pytest.mark.parametrize(
"httpx_client",
(httpx.Client(), httpx.AsyncClient()),
)
def test_request_source_disabled(sentry_init, capture_events, httpx_client, httpx_mock):
httpx_mock.add_response()
sentry_init(
integrations=[HttpxIntegration()],
traces_sample_rate=1.0,
enable_http_request_source=False,
http_request_source_threshold_ms=0,
)

events = capture_events()

url = "http://example.com/"

with start_transaction(name="test_transaction"):
if asyncio.iscoroutinefunction(httpx_client.get):
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
else:
httpx_client.get(url)

(event,) = events

span = event["spans"][-1]
assert span["description"].startswith("GET")

data = span.get("data", {})

assert SPANDATA.CODE_LINENO not in data
assert SPANDATA.CODE_NAMESPACE not in data
assert SPANDATA.CODE_FILEPATH not in data
assert SPANDATA.CODE_FUNCTION not in data


@pytest.mark.parametrize("enable_http_request_source", [None, True])
@pytest.mark.parametrize(
"httpx_client",
(httpx.Client(), httpx.AsyncClient()),
)
def test_request_source_enabled(
sentry_init, capture_events, enable_http_request_source, httpx_client, httpx_mock
):
httpx_mock.add_response()
sentry_options = {
"integrations": [HttpxIntegration()],
"traces_sample_rate": 1.0,
"http_request_source_threshold_ms": 0,
}
if enable_http_request_source is not None:
sentry_options["enable_http_request_source"] = enable_http_request_source

sentry_init(**sentry_options)

events = capture_events()

url = "http://example.com/"

with start_transaction(name="test_transaction"):
if asyncio.iscoroutinefunction(httpx_client.get):
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
else:
httpx_client.get(url)

(event,) = events

span = event["spans"][-1]
assert span["description"].startswith("GET")

data = span.get("data", {})

assert SPANDATA.CODE_LINENO in data
assert SPANDATA.CODE_NAMESPACE in data
assert SPANDATA.CODE_FILEPATH in data
assert SPANDATA.CODE_FUNCTION in data


@pytest.mark.parametrize(
"httpx_client",
(httpx.Client(), httpx.AsyncClient()),
)
def test_request_source(sentry_init, capture_events, httpx_client, httpx_mock):
httpx_mock.add_response()

sentry_init(
integrations=[HttpxIntegration()],
traces_sample_rate=1.0,
enable_http_request_source=True,
http_request_source_threshold_ms=0,
)

events = capture_events()

url = "http://example.com/"

with start_transaction(name="test_transaction"):
if asyncio.iscoroutinefunction(httpx_client.get):
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
else:
httpx_client.get(url)

(event,) = events

span = event["spans"][-1]
assert span["description"].startswith("GET")

data = span.get("data", {})

assert SPANDATA.CODE_LINENO in data
assert SPANDATA.CODE_NAMESPACE in data
assert SPANDATA.CODE_FILEPATH in data
assert SPANDATA.CODE_FUNCTION in data

assert type(data.get(SPANDATA.CODE_LINENO)) == int
assert data.get(SPANDATA.CODE_LINENO) > 0
assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.httpx.test_httpx"
assert data.get(SPANDATA.CODE_FILEPATH).endswith(
"tests/integrations/httpx/test_httpx.py"
)

is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
assert is_relative_path

assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source"


@pytest.mark.parametrize(
"httpx_client",
(httpx.Client(), httpx.AsyncClient()),
)
def test_request_source_with_module_in_search_path(
sentry_init, capture_events, httpx_client, httpx_mock
):
"""
Test that request source is relative to the path of the module it ran in
"""
httpx_mock.add_response()
sentry_init(
integrations=[HttpxIntegration()],
traces_sample_rate=1.0,
enable_http_request_source=True,
http_request_source_threshold_ms=0,
)

events = capture_events()

url = "http://example.com/"

with start_transaction(name="test_transaction"):
if asyncio.iscoroutinefunction(httpx_client.get):
from httpx_helpers.helpers import async_get_request_with_client

asyncio.get_event_loop().run_until_complete(
async_get_request_with_client(httpx_client, url)
)
else:
from httpx_helpers.helpers import get_request_with_client

get_request_with_client(httpx_client, url)

(event,) = events

span = event["spans"][-1]
assert span["description"].startswith("GET")

data = span.get("data", {})

assert SPANDATA.CODE_LINENO in data
assert SPANDATA.CODE_NAMESPACE in data
assert SPANDATA.CODE_FILEPATH in data
assert SPANDATA.CODE_FUNCTION in data

assert type(data.get(SPANDATA.CODE_LINENO)) == int
assert data.get(SPANDATA.CODE_LINENO) > 0
assert data.get(SPANDATA.CODE_NAMESPACE) == "httpx_helpers.helpers"
assert data.get(SPANDATA.CODE_FILEPATH) == "httpx_helpers/helpers.py"

is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
assert is_relative_path

if asyncio.iscoroutinefunction(httpx_client.get):
assert data.get(SPANDATA.CODE_FUNCTION) == "async_get_request_with_client"
else:
assert data.get(SPANDATA.CODE_FUNCTION) == "get_request_with_client"


@pytest.mark.parametrize(
"httpx_client",
(httpx.Client(), httpx.AsyncClient()),
)
def test_no_request_source_if_duration_too_short(
sentry_init, capture_events, httpx_client, httpx_mock
):
httpx_mock.add_response()

sentry_init(
integrations=[HttpxIntegration()],
traces_sample_rate=1.0,
enable_http_request_source=True,
http_request_source_threshold_ms=100,
)

events = capture_events()

url = "http://example.com/"

with start_transaction(name="test_transaction"):

@contextmanager
def fake_start_span(*args, **kwargs):
with sentry_sdk.start_span(*args, **kwargs) as span:
pass
span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999)
yield span

with mock.patch(
"sentry_sdk.integrations.httpx.start_span",
fake_start_span,
):
if asyncio.iscoroutinefunction(httpx_client.get):
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
else:
httpx_client.get(url)

(event,) = events

span = event["spans"][-1]
assert span["description"].startswith("GET")

data = span.get("data", {})

assert SPANDATA.CODE_LINENO not in data
assert SPANDATA.CODE_NAMESPACE not in data
assert SPANDATA.CODE_FILEPATH not in data
assert SPANDATA.CODE_FUNCTION not in data


@pytest.mark.parametrize(
"httpx_client",
(httpx.Client(), httpx.AsyncClient()),
)
def test_request_source_if_duration_over_threshold(
sentry_init, capture_events, httpx_client, httpx_mock
):
httpx_mock.add_response()

sentry_init(
integrations=[HttpxIntegration()],
traces_sample_rate=1.0,
enable_db_query_source=True,
db_query_source_threshold_ms=100,
)

events = capture_events()

url = "http://example.com/"

with start_transaction(name="test_transaction"):

@contextmanager
def fake_start_span(*args, **kwargs):
with sentry_sdk.start_span(*args, **kwargs) as span:
pass
span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)
span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001)
yield span

with mock.patch(
"sentry_sdk.integrations.httpx.start_span",
fake_start_span,
):
if asyncio.iscoroutinefunction(httpx_client.get):
asyncio.get_event_loop().run_until_complete(httpx_client.get(url))
else:
httpx_client.get(url)

(event,) = events

span = event["spans"][-1]
assert span["description"].startswith("GET")

data = span.get("data", {})

assert SPANDATA.CODE_LINENO in data
assert SPANDATA.CODE_NAMESPACE in data
assert SPANDATA.CODE_FILEPATH in data
assert SPANDATA.CODE_FUNCTION in data

assert type(data.get(SPANDATA.CODE_LINENO)) == int
assert data.get(SPANDATA.CODE_LINENO) > 0
assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.httpx.test_httpx"
assert data.get(SPANDATA.CODE_FILEPATH).endswith(
"tests/integrations/httpx/test_httpx.py"
)

is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep
assert is_relative_path

assert (
data.get(SPANDATA.CODE_FUNCTION)
== "test_request_source_if_duration_over_threshold"
)


@pytest.mark.parametrize(
"httpx_client",
(httpx.Client(), httpx.AsyncClient()),
Expand Down