Skip to content

Commit f180b1f

Browse files
authored
Starlette: use route name as transaction name if available (#957)
* Starlette: use route name as transaction name if available Unfortunately, we need to do some matching here that Starlette does too, but currently isn't exposed. If/when Kludex/starlette#804 is merged, we can re-use the route from there. closes #833 * detect trailing slash redirects from Starlette and give them a dedicated transaction name * adapt original route for slash redirects
1 parent d829eae commit f180b1f

File tree

2 files changed

+76
-1
lines changed

2 files changed

+76
-1
lines changed

elasticapm/contrib/starlette/__init__.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
3636
from starlette.requests import Request
3737
from starlette.responses import Response
38+
from starlette.routing import Match
3839
from starlette.types import ASGIApp
3940

4041
import elasticapm
@@ -181,7 +182,8 @@ async def _request_started(self, request: Request):
181182
await set_context(
182183
lambda: get_data_from_request(request, self.client.config, constants.TRANSACTION), "request"
183184
)
184-
elasticapm.set_transaction_name("{} {}".format(request.method, request.url.path), override=False)
185+
transaction_name = self.get_route_name(request) or request.url.path
186+
elasticapm.set_transaction_name("{} {}".format(request.method, transaction_name), override=False)
185187

186188
async def _request_finished(self, response: Response):
187189
"""Captures the end of the request processing to APM.
@@ -195,3 +197,34 @@ async def _request_finished(self, response: Response):
195197

196198
result = "HTTP {}xx".format(response.status_code // 100)
197199
elasticapm.set_transaction_result(result, override=False)
200+
201+
def get_route_name(self, request: Request) -> str:
202+
route_name = None
203+
app = request.app
204+
scope = request.scope
205+
routes = app.routes
206+
207+
for route in routes:
208+
match, _ = route.matches(scope)
209+
if match == Match.FULL:
210+
route_name = route.path
211+
break
212+
elif match == Match.PARTIAL and route_name is None:
213+
route_name = route.path
214+
# Starlette magically redirects requests if the path matches a route name with a trailing slash
215+
# appended or removed. To not spam the transaction names list, we do the same here and put these
216+
# redirects all in the same "redirect trailing slashes" transaction name
217+
if not route_name and app.router.redirect_slashes and scope["path"] != "/":
218+
redirect_scope = dict(scope)
219+
if scope["path"].endswith("/"):
220+
redirect_scope["path"] = scope["path"][:-1]
221+
trim = True
222+
else:
223+
redirect_scope["path"] = scope["path"] + "/"
224+
trim = False
225+
for route in routes:
226+
match, _ = route.matches(redirect_scope)
227+
if match != Match.NONE:
228+
route_name = route.path + "/" if trim else route.path[:-1]
229+
break
230+
return route_name

tests/contrib/asyncio/starlette_tests.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ async def hi(request):
5555
pass
5656
return PlainTextResponse("ok")
5757

58+
@app.route("/hi/{name}", methods=["GET"])
59+
async def hi_name(request):
60+
name = request.path_params["name"]
61+
return PlainTextResponse("Hi {}".format(name))
62+
5863
@app.route("/hello", methods=["GET", "POST"])
5964
async def hello(request):
6065
with async_capture_span("test"):
@@ -65,6 +70,14 @@ async def hello(request):
6570
async def raise_exception(request):
6671
raise ValueError()
6772

73+
@app.route("/hi/{name}/with/slash/", methods=["GET", "POST"])
74+
async def with_slash(request):
75+
return PlainTextResponse("Hi {}".format(request.path_params["name"]))
76+
77+
@app.route("/hi/{name}/without/slash", methods=["GET", "POST"])
78+
async def without_slash(request):
79+
return PlainTextResponse("Hi {}".format(request.path_params["name"]))
80+
6881
app.add_middleware(ElasticAPM, client=elasticapm_client)
6982

7083
return app
@@ -226,3 +239,32 @@ def test_starlette_transaction_ignore_urls(app, elasticapm_client):
226239
elasticapm_client.config.update(1, transaction_ignore_urls="/*ello,/world")
227240
response = client.get("/hello")
228241
assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
242+
243+
244+
def test_transaction_name_is_route(app, elasticapm_client):
245+
client = TestClient(app)
246+
247+
response = client.get("/hi/shay")
248+
249+
assert response.status_code == 200
250+
251+
assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
252+
transaction = elasticapm_client.events[constants.TRANSACTION][0]
253+
assert transaction["name"] == "GET /hi/{name}"
254+
assert transaction["context"]["request"]["url"]["pathname"] == "/hi/shay"
255+
256+
257+
@pytest.mark.parametrize(
258+
"url,expected",
259+
(
260+
("/hi/shay/with/slash", "GET /hi/{name}/with/slash"),
261+
("/hi/shay/without/slash/", "GET /hi/{name}/without/slash/"),
262+
),
263+
)
264+
def test_trailing_slash_redirect_detection(app, elasticapm_client, url, expected):
265+
client = TestClient(app)
266+
response = client.get(url, allow_redirects=False)
267+
assert response.status_code == 307
268+
assert len(elasticapm_client.events[constants.TRANSACTION]) == 1
269+
for transaction in elasticapm_client.events[constants.TRANSACTION]:
270+
assert transaction["name"] == expected

0 commit comments

Comments
 (0)