Skip to content

Commit c50425e

Browse files
authored
Merge pull request #1614 from major/fix/rspeed-2941-metrics-root-path
RSPEED-2941: fix REST API metrics when root_path is set
2 parents 6ea1e91 + 3951e59 commit c50425e

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

src/app/main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,14 @@ 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+
# When root_path is set (e.g., /api/lightspeed), the proxy forwards
158+
# requests with the full prefixed path (/api/lightspeed/v1/infer) but
159+
# app_routes_paths contains only application-level paths (/v1/infer).
160+
# Strip the prefix so the path check and metric labels match the routes.
161+
root_path = scope.get("root_path", "")
162+
path: str = scope["path"]
163+
if root_path and path.startswith(root_path + "/"):
164+
path = path[len(root_path) :]
158165
logger.debug("Received request for path: %s", path)
159166

160167
# 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 = "") -> Scope:
1616
"""Build a minimal HTTP ASGI scope."""
17-
return {
17+
scope: Scope = {
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)