Skip to content

Commit ca1dede

Browse files
committed
RSPEED-2941: fix REST API metrics when root_path is set
The metrics middleware reads scope["path"] directly, which contains the full request path including any root_path prefix (e.g., /api/lightspeed/v1/infer). Since app_routes_paths stores only application-level paths (e.g., /v1/infer), the path check fails and metrics are never recorded for any API traffic routed through 3scale. Use get_route_path(scope) from starlette._utils to strip the root_path prefix before matching, consistent with how Starlette Router matches routes internally. Signed-off-by: Major Hayden <major@redhat.com>
1 parent 6ea1e91 commit ca1dede

2 files changed

Lines changed: 65 additions & 3 deletions

File tree

src/app/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,10 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
154154
await self.app(scope, receive, send)
155155
return
156156

157-
path = scope["path"]
157+
root_path = scope.get("root_path", "")
158+
path: str = scope["path"]
159+
if root_path and path.startswith(root_path + "/"):
160+
path = path[len(root_path):]
158161
logger.debug("Received request for path: %s", path)
159162

160163
# Ignore paths that are not part of the app routes.

tests/unit/app/test_main_middleware.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,18 @@
1212
from models.responses import InternalServerErrorResponse
1313

1414

15-
def _make_scope(path: str = "/test") -> dict:
15+
def _make_scope(path: str = "/test", root_path: str = "") -> dict:
1616
"""Build a minimal HTTP ASGI scope."""
17-
return {
17+
scope: dict = {
1818
"type": "http",
1919
"method": "GET",
2020
"path": path,
2121
"query_string": b"",
2222
"headers": [],
2323
}
24+
if root_path:
25+
scope["root_path"] = root_path
26+
return scope
2427

2528

2629
async def _noop_receive() -> dict:
@@ -175,3 +178,59 @@ async def failing_app(_scope: Scope, _receive: Receive, _send: Send) -> None:
175178
mock_metrics.response_duration_seconds.labels.assert_called_once_with("/v1/infer")
176179
mock_metrics.rest_api_calls_total.labels.assert_called_once_with("/v1/infer", 500)
177180
mock_metrics.rest_api_calls_total.labels.return_value.inc.assert_called_once()
181+
182+
183+
@pytest.mark.asyncio
184+
async def test_rest_api_metrics_strips_root_path(
185+
mocker: MockerFixture,
186+
) -> None:
187+
"""Middleware must strip root_path so prefixed requests still match routes."""
188+
mocker.patch("app.main.app_routes_paths", ["/v1/infer"])
189+
mock_metrics = mocker.patch("app.main.metrics")
190+
191+
async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None:
192+
await send({"type": "http.response.start", "status": 200, "headers": []})
193+
await send({"type": "http.response.body", "body": b"ok"})
194+
195+
middleware = RestApiMetricsMiddleware(ok_app)
196+
collector = _ResponseCollector()
197+
198+
# Simulate 3scale forwarding /api/lightspeed/v1/infer with root_path set.
199+
await middleware(
200+
_make_scope("/api/lightspeed/v1/infer", root_path="/api/lightspeed"),
201+
_noop_receive,
202+
collector,
203+
)
204+
205+
assert collector.status_code == 200
206+
# Metrics labels should use the stripped path, not the full prefixed path.
207+
mock_metrics.response_duration_seconds.labels.assert_called_once_with("/v1/infer")
208+
mock_metrics.rest_api_calls_total.labels.assert_called_once_with("/v1/infer", 200)
209+
mock_metrics.rest_api_calls_total.labels.return_value.inc.assert_called_once()
210+
211+
212+
@pytest.mark.asyncio
213+
async def test_rest_api_metrics_no_root_path_unchanged(
214+
mocker: MockerFixture,
215+
) -> None:
216+
"""Without root_path, middleware behaves as before."""
217+
mocker.patch("app.main.app_routes_paths", ["/v1/infer"])
218+
mock_metrics = mocker.patch("app.main.metrics")
219+
220+
async def ok_app(_scope: Scope, _receive: Receive, send: Send) -> None:
221+
await send({"type": "http.response.start", "status": 200, "headers": []})
222+
await send({"type": "http.response.body", "body": b"ok"})
223+
224+
middleware = RestApiMetricsMiddleware(ok_app)
225+
collector = _ResponseCollector()
226+
227+
await middleware(
228+
_make_scope("/v1/infer"),
229+
_noop_receive,
230+
collector,
231+
)
232+
233+
assert collector.status_code == 200
234+
mock_metrics.response_duration_seconds.labels.assert_called_once_with("/v1/infer")
235+
mock_metrics.rest_api_calls_total.labels.assert_called_once_with("/v1/infer", 200)
236+
mock_metrics.rest_api_calls_total.labels.return_value.inc.assert_called_once()

0 commit comments

Comments
 (0)