Skip to content

Commit 9fd05f6

Browse files
authored
#443 Add reference code to errors for user and log
Enh/log requests
2 parents a17053a + b8178db commit 9fd05f6

File tree

14 files changed

+166
-41
lines changed

14 files changed

+166
-41
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
name: 🐛 Bug report
3+
about: Create a report to help us fix it!
4+
title: ''
5+
labels: bug, triage
6+
assignees: ''
7+
8+
---
9+
10+
**Describe the bug**
11+
A clear and concise description of what the bug is.
12+
13+
**Error Reference**
14+
Please indicate which server you were connecting to (e.g., api.aiod.eu).
15+
If the server provided a reference code, please provide it (a hexidecimal string, like `d47cb85f6cf64c158dbb65e1a891903f`).
16+
17+
**To Reproduce**
18+
Please provide a [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example). Preferably a (set of) complete queries which do not behave as expected.
19+
20+
**Expected behavior**
21+
A clear and concise description of what you expected to happen.
22+
23+
**Additional context**
24+
Add any other context about the problem here.

docs/developer/troubleshooting.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## Logging
2+
The REST API uses the built-in [`logging`](https://docs.python.org/3/library/logging.html) module for its logging.
3+
The [log level](https://docs.python.org/3/library/logging.html#logging-levels) for the configuration can be configured
4+
in the `src/config.override.toml` file, as `dev.log_level`.
5+
6+
## Navigating the logs
7+
The REST API will provide an error reference with each exception it raises.
8+
For example, trying to access a dataset that does not exist may result in:
9+
10+
```json
11+
{
12+
'detail': "Dataset '42' not found in the database.",
13+
'reference': 'd47cb85f6cf64c158dbb65e1a891903f'
14+
}
15+
```
16+
17+
To figure out what lead to this error, you can reference the logs.
18+
Unexpected errors, i.e., uncaught ones, are logged at `logging.ERROR` level.
19+
Other errors, such as the one above, are logged at `logging.DEBUG` level.
20+
By default, logging output is at `logging.INFO` level, so if you want to capture all warnings you
21+
must first set it to `logging.DEBUG`.
22+
23+
You can find the error in the docker logs as `docker logs CONTAINER_NAME 2>&1 | grep -e "REFERENCE"`,
24+
e.g.,`docker logs apiserver 2>&1 | grep -e "d47cb85f6cf64c158dbb65e1a891903f"`. The log message will
25+
contain information about the type of request (`GET`) the path and query (`/datasets/v1/42`), and
26+
the body content (in the case of requests that have one).

mkdocs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ nav:
2626
- 'Elastic Search': developer/elastic_search.md
2727
- 'Scripts': developer/scripts.md
2828
- 'Release Workflow': developer/releases.md
29+
- 'Troubleshooting': developer/troubleshooting.md
2930
- 'Contributing': contributing.md
3031

3132
markdown_extensions:

src/config.default.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ password = "ok"
1313
[dev]
1414
reload = true
1515
request_timeout = 10 # seconds
16+
log_level = "INFO" # Python log levels: https://docs.python.org/3/library/logging.html#logging-levels
1617

1718
# Authentication and authorization
1819
[keycloak]

src/error_handling/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from error_handling.error_handling import as_http_exception # noqa:F401
1+
from error_handling.error_handling import as_http_exception, http_exception_handler # noqa:F401

src/error_handling/error_handling.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import json
2+
import logging
13
import traceback
4+
import uuid
5+
from http import HTTPStatus
6+
27
from fastapi import HTTPException, status
8+
from pydantic import BaseModel
9+
from starlette.responses import JSONResponse
310

411

512
def as_http_exception(exception: Exception) -> HTTPException:
@@ -13,3 +20,34 @@ def as_http_exception(exception: Exception) -> HTTPException:
1320
f"{exception}"
1421
),
1522
)
23+
24+
25+
class ErrorSchema(BaseModel):
26+
detail: str
27+
reference: str
28+
29+
30+
async def http_exception_handler(request, exc):
31+
reference = uuid.uuid4().hex
32+
error = ErrorSchema(detail=exc.detail, reference=reference)
33+
content = error.dict()
34+
35+
body_content = "<Data Stream with unknown content>"
36+
if not request._stream_consumed:
37+
body = await request.body()
38+
body_content = json.dumps(json.loads(body)) if body else ""
39+
40+
log_message = str(
41+
dict(
42+
reference=reference,
43+
exception=f"{str(exc)!r}",
44+
method=request.scope["method"],
45+
path=request.scope["path"],
46+
body=body_content,
47+
)
48+
)
49+
log_level = logging.DEBUG
50+
if exc.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
51+
log_level = logging.WARNING
52+
logging.log(log_level, log_message)
53+
return JSONResponse(content, status_code=exc.status_code)

src/main.py

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import pkg_resources
1212
import uvicorn
13-
from fastapi import Depends, FastAPI
13+
from fastapi import Depends, FastAPI, HTTPException
1414
from fastapi.responses import HTMLResponse
1515
from sqlmodel import select
1616

@@ -22,6 +22,7 @@
2222
from database.model.platform.platform_names import PlatformName
2323
from database.session import EngineSingleton, DbSession
2424
from database.setup import create_database, database_exists
25+
from error_handling import http_exception_handler
2526
from routers import resource_routers, parent_routers, enum_routers, uploader_routers
2627
from routers import search_routers
2728
from setup_logger import setup_logger
@@ -101,17 +102,33 @@ def create_app() -> FastAPI:
101102
"""Create the FastAPI application, complete with routes."""
102103
setup_logger()
103104
args = _parse_args()
105+
if args.build_db == "never":
106+
if not database_exists():
107+
logging.warning(
108+
"AI-on-Demand database does not exist on the MySQL server, "
109+
"but `build_db` is set to 'never'. If you are not creating the "
110+
"database through other means, such as MySQL group replication, "
111+
"this likely means that you will get errors or undefined behavior."
112+
)
113+
else:
114+
build_database(args)
115+
104116
pyproject_toml = pkg_resources.get_distribution("aiod_metadata_catalogue")
117+
app = build_app(args.url_prefix, pyproject_toml.version)
118+
return app
119+
120+
121+
def build_app(url_prefix: str = "", version: str = "dev"):
105122
app = FastAPI(
106-
openapi_url=f"{args.url_prefix}/openapi.json",
107-
docs_url=f"{args.url_prefix}/docs",
123+
openapi_url=f"{url_prefix}/openapi.json",
124+
docs_url=f"{url_prefix}/docs",
108125
title="AIoD Metadata Catalogue",
109126
description="This is the Swagger documentation of the AIoD Metadata Catalogue. For the "
110127
"Changelog, refer to "
111128
'<a href="https://github.com/aiondemand/AIOD-rest-api/releases">https'
112129
"://github.com/aiondemand/AIOD-rest-api/releases</a>.",
113-
version=pyproject_toml.version,
114-
swagger_ui_oauth2_redirect_url=f"{args.url_prefix}/docs/oauth2-redirect",
130+
version=version,
131+
swagger_ui_oauth2_redirect_url=f"{url_prefix}/docs/oauth2-redirect",
115132
swagger_ui_init_oauth={
116133
"clientId": KEYCLOAK_CONFIG.get("client_id_swagger"),
117134
"realm": KEYCLOAK_CONFIG.get("realm"),
@@ -120,32 +137,25 @@ def create_app() -> FastAPI:
120137
"scopes": KEYCLOAK_CONFIG.get("scopes"),
121138
},
122139
)
123-
if args.build_db == "never":
124-
if not database_exists():
125-
logging.warning(
126-
"AI-on-Demand database does not exist on the MySQL server, "
127-
"but `build_db` is set to 'never'. If you are not creating the "
128-
"database through other means, such as MySQL group replication, "
129-
"this likely means that you will get errors or undefined behavior."
130-
)
131-
else:
132-
133-
drop_database = args.build_db == "drop-then-build"
134-
create_database(delete_first=drop_database)
135-
AIoDConcept.metadata.create_all(EngineSingleton().engine, checkfirst=True)
136-
with DbSession() as session:
137-
triggers = create_delete_triggers(AIoDConcept)
138-
for trigger in triggers:
139-
session.execute(trigger)
140-
existing_platforms = session.scalars(select(Platform)).all()
141-
if not any(existing_platforms):
142-
session.add_all([Platform(name=name) for name in PlatformName])
143-
session.commit()
144-
145-
add_routes(app, url_prefix=args.url_prefix)
140+
add_routes(app, url_prefix=url_prefix)
141+
app.add_exception_handler(HTTPException, http_exception_handler)
146142
return app
147143

148144

145+
def build_database(args):
146+
drop_database = args.build_db == "drop-then-build"
147+
create_database(delete_first=drop_database)
148+
AIoDConcept.metadata.create_all(EngineSingleton().engine, checkfirst=True)
149+
with DbSession() as session:
150+
triggers = create_delete_triggers(AIoDConcept)
151+
for trigger in triggers:
152+
session.execute(trigger)
153+
existing_platforms = session.scalars(select(Platform)).all()
154+
if not any(existing_platforms):
155+
session.add_all([Platform(name=name) for name in PlatformName])
156+
session.commit()
157+
158+
149159
def main():
150160
"""Run the application. Placed in a separate function, to avoid having global variables"""
151161
args = _parse_args()

src/routers/resource_ai_asset_router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def get_resource_content(
7272
detail=(
7373
"Multiple distributions encountered. "
7474
"Use another endpoint indicating the distribution index `distribution_idx` "
75-
"at the end of the url for a specific distribution.",
75+
"at the end of the url for a specific distribution."
7676
),
7777
)
7878
elif distribution_idx >= len(distributions):

src/setup_logger.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
1+
# ruff: noqa: S101 # We want early fail on startup if logging is not configured correctly
12
import logging
23
from importlib.metadata import version
34

5+
from config import CONFIG
6+
47
format_string = (
58
f"v{version('aiod_metadata_catalogue')}"
69
+ " %(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s"
710
)
811

912

10-
def setup_logger():
13+
def setup_logger(config: dict | None = None):
14+
config = config or CONFIG
15+
assert "dev" in config, "There should be a [dev] section in the configuration.toml file."
16+
assert (
17+
"log_level" in config["dev"]
18+
), "Missing `log_level` setting in the [dev] section of the configuration.toml file."
19+
20+
log_level: str = config["dev"]["log_level"]
21+
levels = logging.getLevelNamesMapping()
22+
assert (
23+
isinstance(log_level, str) and log_level.upper() in levels
24+
), f"The `dev.log_level` is set to {log_level!r} but should be one of {set(levels)!r}."
25+
1126
logging.basicConfig(
12-
level=logging.INFO,
27+
level=levels[log_level],
1328
format=format_string,
1429
datefmt="%Y-%m-%d %H:%M:%S",
1530
)

src/tests/routers/ai_asset_routers/test_router_aiassets_retrieve_content.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,11 @@ def test_endpoints_when_two_distributions(
160160

161161
response = client.get(SAMPLE_ENDPOINT, allow_redirects=False)
162162
assert response.status_code == status.HTTP_409_CONFLICT, response.content
163-
assert response.json()["detail"] == [
163+
assert response.json()["detail"] == (
164164
"Multiple distributions encountered. "
165165
"Use another endpoint indicating the distribution index `distribution_idx` "
166166
"at the end of the url for a specific distribution."
167-
], response.content
167+
), response.content
168168

169169
response0 = client.get(SAMPLE_ENDPOINT + "/0", allow_redirects=False)
170170
assert response0.status_code == status.HTTP_303_SEE_OTHER, response0.content

0 commit comments

Comments
 (0)