From 274b66541b6d0cd508139725e558a3716ff8af36 Mon Sep 17 00:00:00 2001 From: Bilal El Houdaigui Date: Tue, 11 Aug 2026 14:46:24 +0100 Subject: [PATCH] :chart_with_upwards_trend: update resolver metrics and tracking for various outcomes --- README.md | 9 ++++---- app/api/metrics.py | 23 ++++++++++++------- app/api/resources/rapid_view.py | 36 ++++++++++++++++++++---------- app/api/resources/resolver_view.py | 11 ++++++--- tests/test_rapid.py | 10 +++++++-- tests/test_resolver.py | 17 ++++++++++++++ 6 files changed, 77 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5b617d9..dac0209 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,12 @@ python3 -m uvicorn app.main:app --port 8001 --reload `APP_PREFIX` defaults to `/`. Set it only when the app should be served under a path prefix, for example `/api/resolver`. -### Legacy URL resolver metrics +### Resolver outcome metrics -In addition to HTTP metrics, `/metrics` exposes -`legacy_url_resolver_requests_total`. Its `outcome` label records the resolver -result independently of the HTTP status: +In addition to HTTP metrics, `/metrics` exposes `resolver_requests_total`. +Its `endpoint` and `outcome` labels record the resolver result independently +of the HTTP status. Endpoints are `legacy_url`, `stable_id`, `rapid`, and +`rapid_stable_id`: | Outcome | Meaning | | --- | --- | diff --git a/app/api/metrics.py b/app/api/metrics.py index 54e2171..a210d6f 100644 --- a/app/api/metrics.py +++ b/app/api/metrics.py @@ -3,20 +3,27 @@ from prometheus_client import Counter -LEGACY_URL_RESOLVER_REQUESTS = Counter( - "legacy_url_resolver_requests", - "Legacy URL resolver requests by outcome and response mode.", - ("outcome", "response_mode"), +RESOLVER_REQUESTS = Counter( + "resolver_requests", + "Resolver requests by endpoint, outcome, and response mode.", + ("endpoint", "outcome", "response_mode"), ) -def record_legacy_url_resolver_outcome(outcome: str, response_mode: str) -> None: - """Record the product outcome of one legacy resolver request. +def record_resolver_outcome( + endpoint: str, outcome: str, response_mode: str +) -> None: + """Record the product outcome of one resolver request. HTTP status codes retain their protocol meaning, while this metric separates expected user-facing outcomes (such as an HTML interstitial) from failures that need operational attention. """ - LEGACY_URL_RESOLVER_REQUESTS.labels( - outcome=outcome, response_mode=response_mode + RESOLVER_REQUESTS.labels( + endpoint=endpoint, outcome=outcome, response_mode=response_mode ).inc() + + +def record_legacy_url_resolver_outcome(outcome: str, response_mode: str) -> None: + """Record an outcome for the ``/legacy`` endpoint.""" + record_resolver_outcome("legacy_url", outcome, response_mode) diff --git a/app/api/resources/rapid_view.py b/app/api/resources/rapid_view.py index 0d587bb..2865c7e 100644 --- a/app/api/resources/rapid_view.py +++ b/app/api/resources/rapid_view.py @@ -8,6 +8,7 @@ import logging from app.api.error_response import response_error_handler +from app.api.metrics import record_resolver_outcome from app.api.models.resolver import ( RapidResolverResponse, RapidResolverHtmlResponseType, @@ -40,6 +41,7 @@ async def resolve_rapid_stable_id(request: Request, stable_id: str): # Handle only gene stable id for now params = SearchPayload(stable_id=stable_id, type="gene", per_page=10) rapid_archive_url = construct_rapid_archive_url(request) + response_mode = "json" if is_json_request(request) else "html" try: # fm_py performs synchronous Redb file I/O. Run it off the async event @@ -47,7 +49,8 @@ async def resolve_rapid_stable_id(request: Request, stable_id: str): search_results = await run_in_threadpool(get_search_results, params) if not search_results or not search_results.get("matches"): - if is_json_request(request): + record_resolver_outcome("rapid_stable_id", "not_found", response_mode) + if response_mode == "json": return response_error_handler({"status": 404}) res = StableIdResolverResponse( stable_id=stable_id, @@ -67,13 +70,15 @@ async def resolve_rapid_stable_id(request: Request, stable_id: str): results = build_stable_id_resolver_content(metadata_results) stable_id_resolver_response.content = results - if is_json_request(request): + record_resolver_outcome("rapid_stable_id", "resolved", response_mode) + if response_mode == "json": return results return HTMLResponse(generate_rapid_id_page(stable_id_resolver_response)) except Exception as e: logging.error(f"Error: {e}") - if is_json_request(request): + record_resolver_outcome("rapid_stable_id", "internal_error", response_mode) + if response_mode == "json": return response_error_handler({"status": 500, "details": str(e)}) res = StableIdResolverResponse( stable_id=stable_id, @@ -129,15 +134,9 @@ async def resolve_species( assembly_accession_id = format_assembly_accession(species_url_name) if assembly_accession_id is None: - input_error_response = RapidResolverResponse( - response_type=RapidResolverHtmlResponseType.ERROR, - code=422, - resolved_url=f"{ENSEMBL_URL}/genome-selector", - message="Invalid input accession ID", - species_name=species_url_name, - rapid_archive_url=rapid_archive_url, + raise HTTPException( + status_code=422, detail="Invalid input accession ID" ) - return rapid_resolved_response(input_error_response, request) genome_object = get_genome_id_from_assembly_accession_id(assembly_accession_id) @@ -197,7 +196,20 @@ async def resolve_home(request: Request): def rapid_resolved_response(response: RapidResolverResponse, request: Request): - if is_json_request(request): + response_mode = "json" if is_json_request(request) else "html" + if response.response_type == RapidResolverHtmlResponseType.ERROR: + outcome = ( + "invalid_request" + if response.code == 422 + else "not_found" + if response.code == 404 + else "internal_error" + ) + else: + outcome = "resolved" + record_resolver_outcome("rapid", outcome, response_mode) + + if response_mode == "json": if response.response_type == RapidResolverHtmlResponseType.ERROR: raise HTTPException( status_code=response.code, diff --git a/app/api/resources/resolver_view.py b/app/api/resources/resolver_view.py index 2989b37..291dc1f 100644 --- a/app/api/resources/resolver_view.py +++ b/app/api/resources/resolver_view.py @@ -6,6 +6,7 @@ import logging from app.api.error_response import response_error_handler +from app.api.metrics import record_resolver_outcome from app.api.models.resolver import SearchPayload, StableIdResolverResponse from app.api.utils.commons import build_stable_id_resolver_content, is_json_request from app.api.utils.metadata import get_metadata @@ -31,13 +32,15 @@ async def resolve( ): params = SearchPayload(stable_id=stable_id, type=type, per_page=10) + response_mode = "json" if is_json_request(request) else "html" try: # fm_py performs synchronous Redb file I/O. Run it off the async event # loop so concurrent resolver requests can continue to be served. search_results = await run_in_threadpool(get_search_results, params) if not search_results or not search_results.get("matches"): - if is_json_request(request): + record_resolver_outcome("stable_id", "not_found", response_mode) + if response_mode == "json": return response_error_handler({"status": 404}) res = StableIdResolverResponse( @@ -61,7 +64,8 @@ async def resolve( results = build_stable_id_resolver_content(metadata_results) stable_id_resolver_response.content = results - if is_json_request(request): + record_resolver_outcome("stable_id", "resolved", response_mode) + if response_mode == "json": return results if len(results) == 1: @@ -74,7 +78,8 @@ async def resolve( return HTMLResponse(generate_resolver_id_page(stable_id_resolver_response)) except Exception as e: logging.error(f"Error: {e}") - if is_json_request(request): + record_resolver_outcome("stable_id", "internal_error", response_mode) + if response_mode == "json": return response_error_handler({"status": 500, "details": str(e)}) res = StableIdResolverResponse( stable_id=stable_id, code=500, message=str(e), content=None diff --git a/tests/test_rapid.py b/tests/test_rapid.py index d3cdc4e..1b167bc 100644 --- a/tests/test_rapid.py +++ b/tests/test_rapid.py @@ -191,12 +191,14 @@ def test_rapid_species_404_not_found( self.assertEqual(response.status_code, 404) # Test invalid url entity - def test_rapid_species_422_unprocessable_entity(self): + @patch("app.api.resources.rapid_view.record_resolver_outcome") + def test_rapid_species_422_unprocessable_entity(self, mock_record_outcome): response = self.client.get( f"{self.mock_rapid_api_url}/Invalid_Name/", headers={"accept": "application/json"}, ) self.assertEqual(response.status_code, 422) + mock_record_outcome.assert_called_once_with("rapid", "invalid_request", "json") # Test POST def test_rapid_species_post_method_not_allowed(self): @@ -225,8 +227,9 @@ def test_rapid_species_500_internal_server_error( ) self.assertEqual(response.status_code, 500) + @patch("app.api.resources.rapid_view.record_resolver_outcome") @patch("app.api.resources.rapid_view.get_search_results") - def test_rapid_id_resolve_404(self, mock_get_search_results): + def test_rapid_id_resolve_404(self, mock_get_search_results, mock_record_outcome): mock_get_search_results.return_value = {} @@ -236,3 +239,6 @@ def test_rapid_id_resolve_404(self, mock_get_search_results): headers={"accept": "application/json"}, ) self.assertEqual(response.status_code, 404) + mock_record_outcome.assert_called_once_with( + "rapid_stable_id", "not_found", "json" + ) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index bc15770..4e7db36 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -139,6 +139,23 @@ def test_resolve_404(self, mock_get_search_results): ) self.assertEqual(response.status_code, 404) + @patch("app.api.resources.resolver_view.record_resolver_outcome") + @patch("app.api.resources.resolver_view.get_search_results") + def test_records_stable_id_not_found_outcome( + self, mock_get_search_results, mock_record_outcome + ): + """Record stable-ID misses independently of their HTTP response.""" + mock_get_search_results.return_value = {} + + response = self.client.get( + f"{self.mock_search_api_url}/{self.stable_id}", + headers={"accept": "application/json"}, + follow_redirects=False, + ) + + self.assertEqual(response.status_code, 404) + mock_record_outcome.assert_called_once_with("stable_id", "not_found", "json") + @patch("app.api.resources.resolver_view.get_search_results") def test_resolve_404_html_includes_archive_url(self, mock_get_search_results): """Offer the main Ensembl archive when a stable ID has no match."""