Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
255fcee
fix(asgi): Stop duplicating root_path in URLs
alexander-alderman-webb Jun 16, 2026
89023f4
starlette and fastapi tests
alexander-alderman-webb Jun 19, 2026
a58e42d
remanining tests
alexander-alderman-webb Jun 19, 2026
42aca21
update
alexander-alderman-webb Jun 19, 2026
ad1d00f
update
alexander-alderman-webb Jun 19, 2026
539b1cd
logic error
alexander-alderman-webb Jun 19, 2026
a7112ba
fix quart tests
alexander-alderman-webb Jun 19, 2026
574b5e5
fix django tests
alexander-alderman-webb Jun 19, 2026
e7c7fab
fix django tests
alexander-alderman-webb Jun 19, 2026
79cf430
Merge branch 'master' into webb/asgi/double-mount-prefix
alexander-alderman-webb Jun 19, 2026
518acbe
fix quart url
alexander-alderman-webb Jun 19, 2026
b420587
update docstrings
alexander-alderman-webb Jun 19, 2026
eda0dec
revert litestar and starlite tests as behavior unchanged
alexander-alderman-webb Jun 23, 2026
dc3d352
update django condition
alexander-alderman-webb Jun 23, 2026
a96d284
restore whitespace
alexander-alderman-webb Jun 23, 2026
0b1570b
only change starlette and fastapi
alexander-alderman-webb Jun 26, 2026
e5d65f8
Merge branch 'master' into webb/asgi/double-mount-prefix
alexander-alderman-webb Jun 26, 2026
ae1b1d0
update url.path
alexander-alderman-webb Jun 26, 2026
cb28622
add path_includes_root_path in channels middleware
alexander-alderman-webb Jun 26, 2026
7d263e4
remove test
alexander-alderman-webb Jun 26, 2026
c3050ce
use enum and tolerate both
alexander-alderman-webb Jun 26, 2026
557c418
remove default arguments
alexander-alderman-webb Jun 26, 2026
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
25 changes: 20 additions & 5 deletions sentry_sdk/integrations/_asgi_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,19 @@
asgi_scope: "Dict[str, Any]",
default_scheme: "Literal['ws', 'http']",
host: "Optional[Union[AnnotatedValue, str]]",
path_includes_root_path: "bool" = True,
) -> str:
"""
Extract URL from the ASGI scope, without also including the querystring.
"""
scheme = asgi_scope.get("scheme", default_scheme)

server = asgi_scope.get("server", None)
path = asgi_scope.get("root_path", "") + asgi_scope.get("path", "")
path = (
asgi_scope.get("path", "")
if path_includes_root_path
else asgi_scope.get("root_path", "") + asgi_scope.get("path", "")
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
)

if host:
return "%s://%s%s" % (scheme, host, path)
Expand Down Expand Up @@ -81,7 +86,9 @@
return asgi_scope.get("client")[0]


def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]":
def _get_request_data(
asgi_scope: "Any", path_includes_root_path: "bool" = True
) -> "Dict[str, Any]":
"""
Returns data related to the HTTP request from the ASGI scope.
"""
Expand All @@ -96,7 +103,10 @@
request_data["query_string"] = _get_query(asgi_scope)

request_data["url"] = _get_url(
asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
asgi_scope,
"http" if ty == "http" else "ws",
headers.get("host"),
path_includes_root_path=path_includes_root_path,
)

client = asgi_scope.get("client")
Expand All @@ -106,7 +116,9 @@
return request_data


def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]":
def _get_request_attributes(
asgi_scope: "Any", path_includes_root_path: "bool" = True
) -> "dict[str, Any]":
"""
Return attributes related to the HTTP request from the ASGI scope.
"""
Expand All @@ -127,12 +139,15 @@
attributes["http.query"] = query

url_without_query_string = _get_url(
asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
asgi_scope,
"http" if ty == "http" else "ws",
headers.get("host"),
path_includes_root_path=path_includes_root_path,
)
query_string = _get_query(asgi_scope)
attributes["url.full"] = (

Check warning on line 148 in sentry_sdk/integrations/_asgi_common.py

View check run for this annotation

@sentry/warden / warden: code-review

`url.path` attribute in `_get_request_attributes` always prepends `root_path`, ignoring `path_includes_root_path`

While `url.full` is now correctly computed via `_get_url(..., path_includes_root_path=...)`, the `url.path` attribute is still unconditionally set to `root_path + path` regardless of the `path_includes_root_path` parameter. For ASGI-spec-compliant frameworks (where `scope["path"]` already includes `scope["root_path"]`), this duplicates the `root_path` segment in `url.path` — the same bug this PR fixes for `url.full`.
Comment thread
alexander-alderman-webb marked this conversation as resolved.
f"{url_without_query_string}?{query_string}"
if query_string is not None

Check warning on line 150 in sentry_sdk/integrations/_asgi_common.py

View check run for this annotation

@sentry/warden / warden: find-bugs

`url.path` always duplicates `root_path` regardless of `path_includes_root_path`

`attributes["url.path"]` unconditionally prepends `root_path` to `path`, so when `path_includes_root_path=True` (the default, meaning `scope["path"]` already contains `root_path`) the root path is duplicated — the same bug this PR fixes for `url.full`.
else url_without_query_string
)

Expand Down
40 changes: 34 additions & 6 deletions sentry_sdk/integrations/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"mechanism_type",
"span_origin",
"http_methods_to_capture",
"path_includes_root_path",
)

def __init__(
Expand All @@ -116,6 +117,7 @@
span_origin: str = "manual",
http_methods_to_capture: "Tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE,
asgi_version: "Optional[int]" = None,
path_includes_root_path: bool = True,
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated
) -> None:
"""
Instrument an ASGI application with Sentry. Provides HTTP/websocket
Expand Down Expand Up @@ -148,10 +150,11 @@
)

self.transaction_style = transaction_style
self.mechanism_type = mechanism_type
self.span_origin = span_origin
self.app = app

Check warning on line 155 in sentry_sdk/integrations/asgi.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[XU7-6T4] `url.path` always duplicates `root_path` regardless of `path_includes_root_path` (additional location)

`attributes["url.path"]` unconditionally prepends `root_path` to `path`, so when `path_includes_root_path=True` (the default, meaning `scope["path"]` already contains `root_path`) the root path is duplicated — the same bug this PR fixes for `url.full`.
self.http_methods_to_capture = http_methods_to_capture
self.path_includes_root_path = path_includes_root_path

Check warning on line 157 in sentry_sdk/integrations/asgi.py

View check run for this annotation

@sentry/warden / warden: code-review

[BH7-ARG] `url.path` attribute in `_get_request_attributes` always prepends `root_path`, ignoring `path_includes_root_path` (additional location)

While `url.full` is now correctly computed via `_get_url(..., path_includes_root_path=...)`, the `url.path` attribute is still unconditionally set to `root_path + path` regardless of the `path_includes_root_path` parameter. For ASGI-spec-compliant frameworks (where `scope["path"]` already includes `scope["root_path"]`), this duplicates the `root_path` segment in `url.path` — the same bug this PR fixes for `url.full`.

if asgi_version is None:
if _looks_like_asgi3(app):
Expand Down Expand Up @@ -319,7 +322,8 @@
with span_ctx as span:
if isinstance(span, StreamedSpan):
for attribute, value in _get_request_attributes(
scope
scope,
path_includes_root_path=self.path_includes_root_path,

Check warning on line 326 in sentry_sdk/integrations/asgi.py

View check run for this annotation

@sentry/warden / warden: code-review

[BH7-ARG] `url.path` attribute in `_get_request_attributes` always prepends `root_path`, ignoring `path_includes_root_path` (additional location)

While `url.full` is now correctly computed via `_get_url(..., path_includes_root_path=...)`, the `url.path` attribute is still unconditionally set to `root_path + path` regardless of the `path_includes_root_path` parameter. For ASGI-spec-compliant frameworks (where `scope["path"]` already includes `scope["root_path"]`), this duplicates the `root_path` segment in `url.path` — the same bug this PR fixes for `url.full`.
).items():
span.set_attribute(attribute, value)

Expand Down Expand Up @@ -401,7 +405,11 @@
self, event: "Event", hint: "Hint", asgi_scope: "Any"
) -> "Optional[Event]":
request_data = event.get("request", {})
request_data.update(_get_request_data(asgi_scope))
request_data.update(
_get_request_data(
asgi_scope, path_includes_root_path=self.path_includes_root_path
)
)
event["request"] = deepcopy(request_data)

# Only set transaction name if not already set by Starlette or FastAPI (or other frameworks)
Expand Down Expand Up @@ -447,7 +455,12 @@
if endpoint:
name = transaction_from_function(endpoint) or ""
else:
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
name = _get_url(
asgi_scope,
"http" if ty == "http" else "ws",
host=None,
path_includes_root_path=self.path_includes_root_path,
)
source = TransactionSource.URL

elif transaction_style == "url":
Expand All @@ -459,7 +472,12 @@
if path is not None:
name = path
else:
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
name = _get_url(
asgi_scope,
"http" if ty == "http" else "ws",
host=None,
path_includes_root_path=self.path_includes_root_path,
)
source = TransactionSource.URL

if name is None:
Expand All @@ -484,7 +502,12 @@
if endpoint:
name = qualname_from_function(endpoint) or ""
else:
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
name = _get_url(
asgi_scope,
"http" if ty == "http" else "ws",
host=None,
path_includes_root_path=self.path_includes_root_path,
)
source = SegmentSource.URL.value

elif segment_style == "url":
Expand All @@ -496,7 +519,12 @@
if path is not None:
name = path
else:
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
name = _get_url(
asgi_scope,
"http" if ty == "http" else "ws",
host=None,
path_includes_root_path=self.path_includes_root_path,
)
source = SegmentSource.URL.value

if name is None:
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/integrations/django/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ async def sentry_patched_asgi_handler(
unsafe_context_data=True,
span_origin=DjangoIntegration.origin,
http_methods_to_capture=integration.http_methods_to_capture,
path_includes_root_path=False,
)._run_asgi3

return await middleware(scope, receive, send)
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/integrations/litestar.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
mechanism_type="asgi",
span_origin=span_origin,
asgi_version=3,
path_includes_root_path=False,

Check warning on line 95 in sentry_sdk/integrations/litestar.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[XU7-6T4] `url.path` always duplicates `root_path` regardless of `path_includes_root_path` (additional location)

`attributes["url.path"]` unconditionally prepends `root_path` to `path`, so when `path_includes_root_path=True` (the default, meaning `scope["path"]` already contains `root_path`) the root path is duplicated — the same bug this PR fixes for `url.full`.
)

def _capture_request_exception(self, exc: Exception) -> None:
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/integrations/quart.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
lambda *a, **kw: old_app(self, *a, **kw),
span_origin=QuartIntegration.origin,
asgi_version=3,
path_includes_root_path=False,

Check warning on line 99 in sentry_sdk/integrations/quart.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[XU7-6T4] `url.path` always duplicates `root_path` regardless of `path_includes_root_path` (additional location)

`attributes["url.path"]` unconditionally prepends `root_path` to `path`, so when `path_includes_root_path=True` (the default, meaning `scope["path"]` already contains `root_path`) the root path is duplicated — the same bug this PR fixes for `url.full`.
)
return await middleware(scope, receive, send)

Expand Down
8 changes: 6 additions & 2 deletions sentry_sdk/integrations/starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@
)

patch_middlewares()
patch_asgi_app()
# Starlette's Mount includes scope["root_path"] in scope["path"] starting with:
# https://github.com/Kludex/starlette/commit/e8f0dcd54e4ceec47e02c45f5275374e292339ad.

Check warning on line 147 in sentry_sdk/integrations/starlette.py

View check run for this annotation

@sentry/warden / warden: code-review

[BH7-ARG] `url.path` attribute in `_get_request_attributes` always prepends `root_path`, ignoring `path_includes_root_path` (additional location)

While `url.full` is now correctly computed via `_get_url(..., path_includes_root_path=...)`, the `url.path` attribute is still unconditionally set to `root_path + path` regardless of the `path_includes_root_path` parameter. For ASGI-spec-compliant frameworks (where `scope["path"]` already includes `scope["root_path"]`), this duplicates the `root_path` segment in `url.path` — the same bug this PR fixes for `url.full`.
path_includes_root_path = version >= (0, 33)

Check warning on line 148 in sentry_sdk/integrations/starlette.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[XU7-6T4] `url.path` always duplicates `root_path` regardless of `path_includes_root_path` (additional location)

`attributes["url.path"]` unconditionally prepends `root_path` to `path`, so when `path_includes_root_path=True` (the default, meaning `scope["path"]` already contains `root_path`) the root path is duplicated — the same bug this PR fixes for `url.full`.
patch_asgi_app(path_includes_root_path=path_includes_root_path)
patch_request_response()

if version >= (0, 24):
Expand Down Expand Up @@ -427,7 +430,7 @@
Middleware.__init__ = _sentry_middleware_init


def patch_asgi_app() -> None:
def patch_asgi_app(path_includes_root_path: "bool") -> None:
"""
Instrument Starlette ASGI app using the SentryAsgiMiddleware.
"""
Expand All @@ -451,6 +454,7 @@
else DEFAULT_HTTP_METHODS_TO_CAPTURE
),
asgi_version=3,
path_includes_root_path=path_includes_root_path,
)

return await middleware(scope, receive, send)
Expand Down
45 changes: 45 additions & 0 deletions tests/integrations/fastapi/test_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@

def fastapi_app_factory():
app = FastAPI()
mounted_app = FastAPI()

@app.get("/error")
async def _error():
Expand All @@ -74,6 +75,7 @@
capture_message("Hi")
return {"message": "Hi"}

@mounted_app.get("/nomessage")
@app.delete("/nomessage")
@app.get("/nomessage")
@app.head("/nomessage")
Expand Down Expand Up @@ -117,7 +119,9 @@
):
capture_message("hi")
return {"status": "ok"}

Check warning on line 122 in tests/integrations/fastapi/test_fastapi.py

View check run for this annotation

@sentry/warden / warden: code-review

[BH7-ARG] `url.path` attribute in `_get_request_attributes` always prepends `root_path`, ignoring `path_includes_root_path` (additional location)

While `url.full` is now correctly computed via `_get_url(..., path_includes_root_path=...)`, the `url.path` attribute is still unconditionally set to `root_path + path` regardless of the `path_includes_root_path` parameter. For ASGI-spec-compliant frameworks (where `scope["path"]` already includes `scope["root_path"]`), this duplicates the `root_path` segment in `url.path` — the same bug this PR fixes for `url.full`.
app.mount("/root", mounted_app)

return app


Expand Down Expand Up @@ -1043,6 +1047,47 @@
assert event2["request"]["method"] == "HEAD"


@pytest.mark.parametrize("span_streaming", [True, False])
def test_request_url(sentry_init, capture_events, capture_items, span_streaming):
sentry_init(
traces_sample_rate=1.0,
send_default_pii=True,
integrations=[
StarletteIntegration(),
],
_experiments={
"trace_lifecycle": "stream" if span_streaming else "static",
},
)

starlette_app = fastapi_app_factory()

client = TestClient(starlette_app)

if span_streaming:
items = capture_items("span")

client.get("/root/nomessage")
sentry_sdk.flush()
spans = [item.payload for item in items]

(server_span,) = (
span
for span in spans
if span["attributes"].get("sentry.op") == "http.server"
)
assert server_span["attributes"]["url.full"] == (
"http://testserver/root/nomessage"
)
else:
events = capture_events()

client.get("/root/nomessage")

Check warning on line 1086 in tests/integrations/fastapi/test_fastapi.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[XU7-6T4] `url.path` always duplicates `root_path` regardless of `path_includes_root_path` (additional location)

`attributes["url.path"]` unconditionally prepends `root_path` to `path`, so when `path_includes_root_path=True` (the default, meaning `scope["path"]` already contains `root_path`) the root path is duplicated — the same bug this PR fixes for `url.full`.
(event,) = events
assert event["request"]["url"] == "http://testserver/root/nomessage"


@parametrize_test_configurable_status_codes
def test_configurable_status_codes(
sentry_init,
Expand Down
48 changes: 48 additions & 0 deletions tests/integrations/starlette/test_starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@
"TRACE",
]

mounted_app = starlette.applications.Starlette(
routes=[
starlette.routing.Route("/nomessage", _nomessage, methods=all_methods),
],
)

app = starlette.applications.Starlette(
debug=debug,
routes=[
Expand All @@ -160,6 +166,7 @@
starlette.routing.Route("/body/json", _body_json, methods=["POST"]),
starlette.routing.Route("/body/form", _body_form, methods=["POST"]),
starlette.routing.Route("/body/raw", _body_raw, methods=["POST"]),
starlette.routing.Mount("/root", app=mounted_app),
],
middleware=middleware,
)
Expand Down Expand Up @@ -1477,6 +1484,47 @@
assert event["request"]["method"] == "GET"


@pytest.mark.parametrize("span_streaming", [True, False])
def test_request_url(sentry_init, capture_events, capture_items, span_streaming):
sentry_init(
traces_sample_rate=1.0,
send_default_pii=True,
integrations=[
StarletteIntegration(),
],
_experiments={
"trace_lifecycle": "stream" if span_streaming else "static",
},
)

starlette_app = starlette_app_factory()

client = TestClient(starlette_app)

if span_streaming:
items = capture_items("span")

client.get("/root/nomessage")
sentry_sdk.flush()
spans = [item.payload for item in items]

(server_span,) = (
span
for span in spans
if span["attributes"].get("sentry.op") == "http.server"
)
assert server_span["attributes"]["url.full"] == (
"http://testserver/root/nomessage"
)
else:
events = capture_events()

client.get("/root/nomessage")

(event,) = events
assert event["request"]["url"] == "http://testserver/root/nomessage"
Comment thread
alexander-alderman-webb marked this conversation as resolved.


Check warning on line 1527 in tests/integrations/starlette/test_starlette.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[XU7-6T4] `url.path` always duplicates `root_path` regardless of `path_includes_root_path` (additional location)

`attributes["url.path"]` unconditionally prepends `root_path` to `path`, so when `path_includes_root_path=True` (the default, meaning `scope["path"]` already contains `root_path`) the root path is duplicated — the same bug this PR fixes for `url.full`.
@pytest.mark.skipif(
STARLETTE_VERSION < (0, 21),
reason="Requires Starlette >= 0.21, because earlier versions do not support HTTP 'HEAD' requests",
Expand Down
Loading