Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
31 changes: 21 additions & 10 deletions service/src/middleware/access_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,28 @@
def _route_template(scope) -> str:
"""Low-cardinality route template (e.g. /echo/{name}).

Uses the endpoint Starlette recorded on the scope during routing and maps it
back to its route template — O(routes) identity checks, no regex re-match
(the router already did the matching). Unmatched requests resolve to a
constant placeholder rather than leaking the raw path.
Reads the route the router recorded on the scope during routing (no regex
re-match) and returns its template. Version-robust across the framework's
routing reworks: prefers ``scope["route"]`` (newer Starlette sets it) and
reads ``path_format`` (newer) or ``path`` (older); falls back to mapping the
recorded ``scope["endpoint"]`` back to its route. Unmatched requests (404 /
OPTIONS preflight) resolve to a constant placeholder rather than leaking the
raw path (which can carry PII and explode http.route cardinality).
"""
endpoint = scope.get("endpoint")
if endpoint is None:
return _UNMATCHED # 404 / 405 / OPTIONS preflight — never log the raw path
for route in getattr(scope.get("app"), "routes", []):
if getattr(route, "endpoint", None) is endpoint:
return getattr(route, "path", None) or _UNMATCHED
route = scope.get("route")
if route is None:
endpoint = scope.get("endpoint")
if endpoint is not None:
for r in getattr(scope.get("app"), "routes", []):
if getattr(r, "endpoint", None) is endpoint:
route = r
break
if route is not None:
return (
getattr(route, "path_format", None)
or getattr(route, "path", None)
or _UNMATCHED
)
return _UNMATCHED


Expand Down
39 changes: 38 additions & 1 deletion service/tests/test_access_log_mw.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,44 @@
from fastapi.testclient import TestClient
from structlog.testing import capture_logs

from src.middleware.access_log import AccessLogMiddleware
from src.middleware.access_log import AccessLogMiddleware, _route_template


class _FakeRoute:
def __init__(self, *, endpoint=None, path=None, path_format=None):
self.endpoint = endpoint
if path is not None:
self.path = path
if path_format is not None:
self.path_format = path_format


def test_route_template_reads_path_format_then_path():
# Newer Starlette sets scope["route"] and exposes the template as path_format
# (route.path is None); older exposes it as path. Be robust to both — the
# regression that logged "__unmatched__" for every request came from reading
# only .path against a newer Starlette where it is None.
newer = _FakeRoute(path_format="/users/{id}") # 1.x: path absent/None
assert _route_template({"route": newer}) == "/users/{id}"
older = _FakeRoute(path="/users/{id}") # 0.x: only path
assert _route_template({"route": older}) == "/users/{id}"


def test_route_template_falls_back_to_endpoint_mapping():
def ep():
pass

route = _FakeRoute(endpoint=ep, path_format="/echo/{name}")

class _App:
routes = [route]

# No scope["route"], but scope["endpoint"] + app.routes lets us recover it.
assert _route_template({"endpoint": ep, "app": _App()}) == "/echo/{name}"


def test_route_template_unmatched_when_nothing_recorded():
assert _route_template({}) == "__unmatched__" # 404 / OPTIONS preflight


def _app():
Expand Down
Loading