Skip to content

Commit 98a89a2

Browse files
Add logging configuration and setup (#306)
Related to #182. Adds logging configuration and setup. - By default log everything to `develop.log` - Additional log sinks can be defined in `config.toml` - Every request is automatically assigned a request identifier, which is automatically included in every log call in that request. - The request and response is (partially) logged with every request The exact interaction for logging response and request content needs to be examined (which should be logged, make use of background tasks, ...). --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 3f1a7ac commit 98a89a2

8 files changed

Lines changed: 135 additions & 12 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
docker/mysql/data
2+
*.log
3+
logs/
24
.DS_Store
35

46
# Byte-compiled / optimized / DLL files

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ classifiers = [
1414
]
1515
dependencies = [
1616
"fastapi",
17+
"loguru",
1718
"cryptography",
1819
"pydantic",
1920
"uvicorn",

src/config.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import functools
2-
import logging
32
import os
43
import tomllib
54
import typing
65
from pathlib import Path
76

87
from dotenv import load_dotenv
9-
10-
logger = logging.getLogger(__name__)
8+
from loguru import logger
119

1210
TomlTable = dict[str, typing.Any]
1311

@@ -27,9 +25,13 @@
2725
_dotenv_file = Path(os.getenv(DOTENV_FILE_ENV, _config_directory / ".env"))
2826
_dotenv_file = _dotenv_file.expanduser().absolute()
2927

30-
logger.info("Configuration directory is '%s'", _config_directory)
31-
logger.info("Loading configuration file from '%s'", _config_file)
32-
logger.info("Loading environment variables from '%s'", _dotenv_file)
28+
29+
logger.info(
30+
"Determined configuration sources.",
31+
configuration_directory=_config_directory,
32+
configuration_file=_config_file,
33+
dotenv_file=_dotenv_file,
34+
)
3335

3436
load_dotenv(dotenv_path=_dotenv_file)
3537

@@ -77,5 +79,6 @@ def load_database_configuration(file: Path = _config_file) -> TomlTable:
7779
return database_configuration
7880

7981

80-
def load_configuration(file: Path = _config_file) -> TomlTable:
82+
def load_configuration(file: Path | None = None) -> TomlTable:
83+
file = file or _config_file
8184
return tomllib.loads(file.read_text())

src/config.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ minio_base_url="https://openml1.win.tue.nl"
44
[development]
55
allow_test_api_keys=true
66

7+
# Any number of logging.NAME configurations can be added.
8+
# NAME is for reference only, it has no meaning otherwise.
9+
# You can add any arguments to `loguru.logger.add`,
10+
# the `sink` variable will be used as first positional argument.
11+
# https://loguru.readthedocs.io/en/stable/api/logger.html
12+
[logging.develop]
13+
sink="develop.log"
14+
# One of loguru levels: TRACE, DEBUG, INFO, SUCCESS, WARNING, ERROR
15+
level="DEBUG"
16+
# Automatically create a new file by date or file size
17+
rotation="50 MB"
18+
# Retention specifies the timespan after which automatic cleanup occurs.
19+
retention="1 day"
20+
compression="gz"
21+
722
[fastapi]
823
root_path=""
924

src/core/logging.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Utility functions for logging."""
2+
3+
import sys
4+
import uuid
5+
from collections.abc import Awaitable, Callable
6+
from pathlib import Path
7+
8+
from loguru import logger
9+
from starlette.requests import Request
10+
from starlette.responses import Response
11+
12+
from config import load_configuration
13+
14+
15+
def setup_log_sinks(configuration_file: Path | None = None) -> None:
16+
"""Configure loguru based on app configuration."""
17+
configuration = load_configuration(configuration_file)
18+
for nickname, sink_configuration in configuration.get("logging", {}).items():
19+
logger.info("Configuring sink", nickname=nickname, **sink_configuration)
20+
sink = sink_configuration.pop("sink")
21+
if sink == "sys.stderr":
22+
sink = sys.stderr
23+
logger.add(sink, serialize=True, **sink_configuration)
24+
25+
26+
async def add_request_context_to_log(
27+
request: Request,
28+
call_next: Callable[[Request], Awaitable[Response]],
29+
) -> Response:
30+
"""Add a unique request id to each log call."""
31+
identifier = uuid.uuid4().hex
32+
with logger.contextualize(request_id=identifier):
33+
return await call_next(request)
34+
35+
36+
async def request_response_logger(
37+
request: Request,
38+
call_next: Callable[[Request], Awaitable[Response]],
39+
) -> Response:
40+
"""Log the incoming request and outgoing response."""
41+
logger.info(
42+
"request",
43+
url=request.url,
44+
headers=request.headers,
45+
cookies=request.cookies,
46+
path_params=request.path_params,
47+
query_params=request.query_params,
48+
)
49+
response: Response = await call_next(request)
50+
logger.info(
51+
"response",
52+
status_code=response.status_code,
53+
headers=response.headers,
54+
media_type=response.media_type,
55+
)
56+
return response

src/main.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import argparse
2-
import logging
2+
import sys
33
from collections.abc import AsyncGenerator
44
from contextlib import asynccontextmanager
5+
from pathlib import Path
56

67
import uvicorn
78
from fastapi import FastAPI
9+
from loguru import logger
810

911
from config import load_configuration
1012
from core.errors import ProblemDetailError, problem_detail_exception_handler
13+
from core.logging import add_request_context_to_log, request_response_logger, setup_log_sinks
1114
from database.setup import close_databases
1215
from routers.mldcat_ap.dataset import router as mldcat_ap_router
1316
from routers.openml.datasets import router as datasets_router
@@ -55,12 +58,25 @@ def _parse_args() -> argparse.Namespace:
5558
return parser.parse_args()
5659

5760

58-
def create_api() -> FastAPI:
59-
fastapi_kwargs = load_configuration()["fastapi"]
61+
def create_api(configuration_file: Path | None = None) -> FastAPI:
62+
# Default logging configuration so we have logs during setup
63+
logger.remove()
64+
setup_sink = logger.add(sys.stderr, serialize=True)
65+
setup_log_sinks(configuration_file)
66+
67+
fastapi_kwargs = load_configuration(configuration_file)["fastapi"]
68+
logger.info("Creating FastAPI App", lifespan=lifespan, **fastapi_kwargs)
6069
app = FastAPI(**fastapi_kwargs, lifespan=lifespan)
6170

71+
logger.info("Setting up middleware and exception handlers.")
72+
# Order matters! Each added middleware wraps the previous, creating a stack.
73+
# See also: https://fastapi.tiangolo.com/tutorial/middleware/#multiple-middleware-execution-order
74+
app.middleware("http")(request_response_logger)
75+
app.middleware("http")(add_request_context_to_log)
76+
6277
app.add_exception_handler(ProblemDetailError, problem_detail_exception_handler) # type: ignore[arg-type]
6378

79+
logger.info("Adding routers to app")
6480
app.include_router(datasets_router)
6581
app.include_router(qualities_router)
6682
app.include_router(mldcat_ap_router)
@@ -72,11 +88,13 @@ def create_api() -> FastAPI:
7288
app.include_router(study_router)
7389
app.include_router(setup_router)
7490
app.include_router(run_router)
91+
92+
logger.info("App setup completed.")
93+
logger.remove(setup_sink)
7594
return app
7695

7796

7897
def main() -> None:
79-
logging.basicConfig(level=logging.INFO)
8098
args = _parse_args()
8199
uvicorn.run(
82100
app="main:create_api",

tests/config.test.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
arff_base_url="https://test.openml.org"
2+
minio_base_url="https://openml1.win.tue.nl"
3+
4+
[development]
5+
allow_test_api_keys=true
6+
7+
[logging.develop]
8+
sink="sys.stderr"
9+
level="DEBUG"
10+
11+
[fastapi]
12+
root_path=""
13+
14+
[databases.defaults]
15+
host="database"
16+
port="3306"
17+
# SQLAlchemy `dialect` and `driver`: https://docs.sqlalchemy.org/en/20/dialects/index.html
18+
drivername="mysql+aiomysql"
19+
20+
[databases.expdb]
21+
database="openml_expdb"
22+
23+
[databases.openml]
24+
database="openml"
25+
26+
[routing]
27+
minio_url="http://minio:9000/"
28+
server_url="http://php-api:80/"

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ async def php_api() -> AsyncIterator[httpx.AsyncClient]:
7373
async def py_api(
7474
expdb_test: AsyncConnection, user_test: AsyncConnection
7575
) -> AsyncIterator[httpx.AsyncClient]:
76-
app = create_api()
76+
app = create_api(Path(__file__).parent / "config.test.toml")
7777

7878
# We use async generator functions because fixtures may not be called directly.
7979
# The async generator returns the test connections for FastAPI to handle properly

0 commit comments

Comments
 (0)