Skip to content

Commit fee5f8a

Browse files
fix(starlette): Stop duplicating scope["root_path"] in URLs (#6579)
Add the `root_path_in_path` parameter to functions used for setting the `url.path` and `url.full` attributes. When `root_path_in_path` is `_RootPathInPath.EXCLUDED`, preserve the existing behavior of prepending `scope["root_path"]` when building the URL. The other `_RootPathInPath.EITHER` option is used by the Starlette integration. It preserves Starlette's behavior of accepting ASGI scopes where `scope["path"]` either includes or excludes `scope["root_path"]`. The detection is based on Starlette's route resolution. Closes #6577
1 parent 7787e6d commit fee5f8a

7 files changed

Lines changed: 194 additions & 40 deletions

File tree

sentry_sdk/consts.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,12 @@ class SPANDATA:
10201020
Example: "details"
10211021
"""
10221022

1023+
URL_PATH = "url.path"
1024+
"""
1025+
The URI path component.
1026+
Example: "/foo"
1027+
"""
1028+
10231029
URL_QUERY = "url.query"
10241030
"""
10251031
The query string present in the URL. Note that this does not contain the leading ? character, while the `http.query` attribute does.

sentry_sdk/integrations/_asgi_common.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import urllib
2+
from enum import Enum
23
from typing import TYPE_CHECKING
34

45
from sentry_sdk.integrations._wsgi_common import _filter_headers
@@ -12,6 +13,11 @@
1213
from sentry_sdk.utils import AnnotatedValue
1314

1415

16+
class _RootPathInPath(Enum):
17+
EXCLUDED = "excluded"
18+
EITHER = "either"
19+
20+
1521
def _get_headers(asgi_scope: "Any") -> "Dict[str, str]":
1622
"""
1723
Extract headers from the ASGI scope, in the format that the Sentry protocol expects.
@@ -28,18 +34,34 @@ def _get_headers(asgi_scope: "Any") -> "Dict[str, str]":
2834
return headers
2935

3036

37+
def _get_path(
38+
asgi_scope: "Dict[str, Any]", root_path_in_path: "_RootPathInPath"
39+
) -> "str":
40+
if root_path_in_path is _RootPathInPath.EXCLUDED:
41+
return asgi_scope.get("root_path", "") + asgi_scope.get("path", "")
42+
43+
# Inverse of https://github.com/Kludex/starlette/blob/de970d7b3facb853eb7ad077decbf3d94f2aab6c/starlette/_utils.py#L96
44+
path = asgi_scope["path"]
45+
root_path = asgi_scope.get("root_path", "")
46+
47+
if not root_path or path == root_path or path.startswith(root_path + "/"):
48+
return path
49+
50+
return root_path + path
51+
52+
3153
def _get_url(
3254
asgi_scope: "Dict[str, Any]",
3355
default_scheme: "Literal['ws', 'http']",
3456
host: "Optional[Union[AnnotatedValue, str]]",
57+
path: str,
3558
) -> str:
3659
"""
3760
Extract URL from the ASGI scope, without also including the querystring.
3861
"""
3962
scheme = asgi_scope.get("scheme", default_scheme)
4063

4164
server = asgi_scope.get("server", None)
42-
path = asgi_scope.get("root_path", "") + asgi_scope.get("path", "")
4365

4466
if host:
4567
return "%s://%s%s" % (scheme, host, path)
@@ -81,7 +103,10 @@ def _get_ip(asgi_scope: "Any") -> str:
81103
return asgi_scope.get("client")[0]
82104

83105

84-
def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]":
106+
def _get_request_data(
107+
asgi_scope: "Any",
108+
root_path_in_path: "_RootPathInPath",
109+
) -> "Dict[str, Any]":
85110
"""
86111
Returns data related to the HTTP request from the ASGI scope.
87112
"""
@@ -96,7 +121,10 @@ def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]":
96121
request_data["query_string"] = _get_query(asgi_scope)
97122

98123
request_data["url"] = _get_url(
99-
asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
124+
asgi_scope,
125+
"http" if ty == "http" else "ws",
126+
headers.get("host"),
127+
path=_get_path(asgi_scope=asgi_scope, root_path_in_path=root_path_in_path),
100128
)
101129

102130
client = asgi_scope.get("client")
@@ -106,7 +134,10 @@ def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]":
106134
return request_data
107135

108136

109-
def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]":
137+
def _get_request_attributes(
138+
asgi_scope: "Any",
139+
root_path_in_path: "_RootPathInPath",
140+
) -> "dict[str, Any]":
110141
"""
111142
Return attributes related to the HTTP request from the ASGI scope.
112143
"""
@@ -126,18 +157,21 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]":
126157
if query:
127158
attributes["http.query"] = query
128159

160+
path = _get_path(asgi_scope=asgi_scope, root_path_in_path=root_path_in_path)
161+
attributes["url.path"] = path
162+
129163
url_without_query_string = _get_url(
130-
asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
164+
asgi_scope,
165+
"http" if ty == "http" else "ws",
166+
headers.get("host"),
167+
path=path,
131168
)
132169
query_string = _get_query(asgi_scope)
133170
attributes["url.full"] = (
134171
f"{url_without_query_string}?{query_string}"
135172
if query_string is not None
136173
else url_without_query_string
137174
)
138-
attributes["url.path"] = asgi_scope.get("root_path", "") + asgi_scope.get(
139-
"path", ""
140-
)
141175

142176
client = asgi_scope.get("client")
143177
if client and should_send_default_pii():

sentry_sdk/integrations/asgi.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
from sentry_sdk.integrations._asgi_common import (
1717
_get_headers,
1818
_get_ip,
19+
_get_path,
1920
_get_request_attributes,
2021
_get_request_data,
2122
_get_url,
23+
_RootPathInPath,
2224
)
2325
from sentry_sdk.integrations._wsgi_common import (
2426
DEFAULT_HTTP_METHODS_TO_CAPTURE,
@@ -105,6 +107,7 @@ class SentryAsgiMiddleware:
105107
"mechanism_type",
106108
"span_origin",
107109
"http_methods_to_capture",
110+
"root_path_in_path",
108111
)
109112

110113
def __init__(
@@ -116,6 +119,7 @@ def __init__(
116119
span_origin: str = "manual",
117120
http_methods_to_capture: "Tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE,
118121
asgi_version: "Optional[int]" = None,
122+
root_path_in_path: "_RootPathInPath" = _RootPathInPath.EXCLUDED,
119123
) -> None:
120124
"""
121125
Instrument an ASGI application with Sentry. Provides HTTP/websocket
@@ -152,6 +156,7 @@ def __init__(
152156
self.span_origin = span_origin
153157
self.app = app
154158
self.http_methods_to_capture = http_methods_to_capture
159+
self.root_path_in_path = root_path_in_path
155160

156161
if asgi_version is None:
157162
if _looks_like_asgi3(app):
@@ -319,7 +324,8 @@ async def _run_app(
319324
with span_ctx as span:
320325
if isinstance(span, StreamedSpan):
321326
for attribute, value in _get_request_attributes(
322-
scope
327+
scope,
328+
root_path_in_path=self.root_path_in_path,
323329
).items():
324330
span.set_attribute(attribute, value)
325331

@@ -401,7 +407,9 @@ def event_processor(
401407
self, event: "Event", hint: "Hint", asgi_scope: "Any"
402408
) -> "Optional[Event]":
403409
request_data = event.get("request", {})
404-
request_data.update(_get_request_data(asgi_scope))
410+
request_data.update(
411+
_get_request_data(asgi_scope, root_path_in_path=self.root_path_in_path)
412+
)
405413
event["request"] = deepcopy(request_data)
406414

407415
# Only set transaction name if not already set by Starlette or FastAPI (or other frameworks)
@@ -447,7 +455,14 @@ def _get_transaction_name_and_source(
447455
if endpoint:
448456
name = transaction_from_function(endpoint) or ""
449457
else:
450-
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
458+
name = _get_url(
459+
asgi_scope,
460+
"http" if ty == "http" else "ws",
461+
host=None,
462+
path=_get_path(
463+
asgi_scope=asgi_scope, root_path_in_path=self.root_path_in_path
464+
),
465+
)
451466
source = TransactionSource.URL
452467

453468
elif transaction_style == "url":
@@ -459,7 +474,14 @@ def _get_transaction_name_and_source(
459474
if path is not None:
460475
name = path
461476
else:
462-
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
477+
name = _get_url(
478+
asgi_scope,
479+
"http" if ty == "http" else "ws",
480+
host=None,
481+
path=_get_path(
482+
asgi_scope=asgi_scope, root_path_in_path=self.root_path_in_path
483+
),
484+
)
463485
source = TransactionSource.URL
464486

465487
if name is None:
@@ -484,7 +506,14 @@ def _get_segment_name_and_source(
484506
if endpoint:
485507
name = qualname_from_function(endpoint) or ""
486508
else:
487-
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
509+
name = _get_url(
510+
asgi_scope,
511+
"http" if ty == "http" else "ws",
512+
host=None,
513+
path=_get_path(
514+
asgi_scope=asgi_scope, root_path_in_path=self.root_path_in_path
515+
),
516+
)
488517
source = SegmentSource.URL.value
489518

490519
elif segment_style == "url":
@@ -496,7 +525,14 @@ def _get_segment_name_and_source(
496525
if path is not None:
497526
name = path
498527
else:
499-
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
528+
name = _get_url(
529+
asgi_scope,
530+
"http" if ty == "http" else "ws",
531+
host=None,
532+
path=_get_path(
533+
asgi_scope=asgi_scope, root_path_in_path=self.root_path_in_path
534+
),
535+
)
500536
source = SegmentSource.URL.value
501537

502538
if name is None:

sentry_sdk/integrations/starlette.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
DidNotEnable,
1616
Integration,
1717
)
18+
from sentry_sdk.integrations._asgi_common import _RootPathInPath
1819
from sentry_sdk.integrations._wsgi_common import (
1920
DEFAULT_HTTP_METHODS_TO_CAPTURE,
2021
HttpCodeRangeContainer,
@@ -143,7 +144,12 @@ def setup_once() -> None:
143144
)
144145

145146
patch_middlewares()
146-
patch_asgi_app()
147+
# Starlette tolerates both starting with:
148+
# https://github.com/Kludex/starlette/commit/e8f0dcd54e4ceec47e02c45f5275374e292339ad.
149+
root_path_in_path = (
150+
_RootPathInPath.EITHER if version >= (0, 33) else _RootPathInPath.EXCLUDED
151+
)
152+
patch_asgi_app(root_path_in_path=root_path_in_path)
147153
patch_request_response()
148154

149155
if version >= (0, 24):
@@ -427,7 +433,7 @@ def _sentry_middleware_init(
427433
Middleware.__init__ = _sentry_middleware_init
428434

429435

430-
def patch_asgi_app() -> None:
436+
def patch_asgi_app(root_path_in_path: "_RootPathInPath") -> None:
431437
"""
432438
Instrument Starlette ASGI app using the SentryAsgiMiddleware.
433439
"""
@@ -451,6 +457,7 @@ async def _sentry_patched_asgi_app(
451457
else DEFAULT_HTTP_METHODS_TO_CAPTURE
452458
),
453459
asgi_version=3,
460+
root_path_in_path=root_path_in_path,
454461
)
455462

456463
return await middleware(scope, receive, send)

tests/integrations/asgi/test_asgi.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -243,30 +243,6 @@ async def test_capture_transaction(
243243
}
244244

245245

246-
@pytest.mark.asyncio
247-
async def test_capture_transaction_with_root_path(
248-
sentry_init,
249-
asgi3_app,
250-
capture_items,
251-
):
252-
sentry_init(
253-
send_default_pii=True,
254-
traces_sample_rate=1.0,
255-
_experiments={"trace_lifecycle": "stream"},
256-
)
257-
app = SentryAsgiMiddleware(asgi3_app)
258-
259-
async with TestClient(app, scope={"root_path": "/api"}) as client:
260-
items = capture_items("span")
261-
await client.get("/some_url")
262-
263-
sentry_sdk.flush()
264-
265-
assert len(items) == 1
266-
span = items[0].payload
267-
assert span["attributes"]["url.path"] == "/api/some_url"
268-
269-
270246
@pytest.mark.asyncio
271247
@pytest.mark.parametrize(
272248
"span_streaming",

tests/integrations/fastapi/test_fastapi.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262

6363
def fastapi_app_factory():
6464
app = FastAPI()
65+
mounted_app = FastAPI()
6566

6667
@app.get("/error")
6768
async def _error():
@@ -74,6 +75,7 @@ async def _message():
7475
capture_message("Hi")
7576
return {"message": "Hi"}
7677

78+
@mounted_app.get("/nomessage")
7779
@app.delete("/nomessage")
7880
@app.get("/nomessage")
7981
@app.head("/nomessage")
@@ -118,6 +120,8 @@ async def body_form(
118120
capture_message("hi")
119121
return {"status": "ok"}
120122

123+
app.mount("/root", mounted_app)
124+
121125
return app
122126

123127

@@ -1043,6 +1047,48 @@ def test_transaction_http_method_custom(sentry_init, capture_events):
10431047
assert event2["request"]["method"] == "HEAD"
10441048

10451049

1050+
@pytest.mark.parametrize("span_streaming", [True, False])
1051+
def test_request_url(sentry_init, capture_events, capture_items, span_streaming):
1052+
sentry_init(
1053+
traces_sample_rate=1.0,
1054+
send_default_pii=True,
1055+
integrations=[
1056+
StarletteIntegration(),
1057+
],
1058+
_experiments={
1059+
"trace_lifecycle": "stream" if span_streaming else "static",
1060+
},
1061+
)
1062+
1063+
starlette_app = fastapi_app_factory()
1064+
1065+
client = TestClient(starlette_app)
1066+
1067+
if span_streaming:
1068+
items = capture_items("span")
1069+
1070+
client.get("/root/nomessage")
1071+
sentry_sdk.flush()
1072+
spans = [item.payload for item in items]
1073+
1074+
(server_span,) = (
1075+
span
1076+
for span in spans
1077+
if span["attributes"].get("sentry.op") == "http.server"
1078+
)
1079+
assert server_span["attributes"][SPANDATA.URL_FULL] == (
1080+
"http://testserver/root/nomessage"
1081+
)
1082+
assert server_span["attributes"][SPANDATA.URL_PATH] == "/root/nomessage"
1083+
else:
1084+
events = capture_events()
1085+
1086+
client.get("/root/nomessage")
1087+
1088+
(event,) = events
1089+
assert event["request"]["url"] == "http://testserver/root/nomessage"
1090+
1091+
10461092
@parametrize_test_configurable_status_codes
10471093
def test_configurable_status_codes(
10481094
sentry_init,

0 commit comments

Comments
 (0)