Skip to content

Commit f379856

Browse files
authored
BUG: Log error details to user_session_log.json (#311)
1 parent cfa983c commit f379856

File tree

4 files changed

+196
-27
lines changed

4 files changed

+196
-27
lines changed

src/fmu_settings_api/__main__.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,27 @@
22

33
import asyncio
44
import sys
5+
import time
56
from collections.abc import AsyncIterator
67
from contextlib import asynccontextmanager, suppress
78

89
import uvicorn
910
from fastapi import FastAPI
11+
from fastapi.exception_handlers import (
12+
http_exception_handler,
13+
request_validation_exception_handler,
14+
)
15+
from fastapi.exceptions import RequestValidationError
1016
from fastapi.routing import APIRoute
1117
from fmu.settings._fmu_dir import UserFMUDirectory
1218
from fmu.settings._init import init_user_fmu_directory
1319
from fmu.settings._resources.user_session_log_manager import UserSessionLogManager
1420
from fmu.settings.models.event_info import EventInfo
21+
from starlette.exceptions import HTTPException as StarletteHTTPException
1522
from starlette.middleware.cors import CORSMiddleware
23+
from starlette.requests import Request
24+
from starlette.responses import Response
25+
from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT
1626

1727
from .config import HttpHeader, settings
1828
from .logging import get_logger, setup_logging
@@ -30,6 +40,56 @@ def custom_generate_unique_id(route: APIRoute) -> str:
3040
logger = get_logger(__name__)
3141

3242

43+
async def logging_http_exception_handler(
44+
request: Request,
45+
exc: Exception,
46+
) -> Response:
47+
"""Log a structured ``request_failed`` event for HTTP exceptions, then delegate."""
48+
if not isinstance(exc, StarletteHTTPException):
49+
raise exc
50+
51+
started_at = getattr(request.state, "request_started_at", None)
52+
logger.warning(
53+
"request_failed",
54+
method=request.method,
55+
path=request.url.path,
56+
status_code=exc.status_code,
57+
error=exc.detail,
58+
error_type=type(exc).__name__,
59+
duration_ms=(
60+
round((time.perf_counter() - started_at) * 1000, 2)
61+
if started_at is not None
62+
else None
63+
),
64+
)
65+
return await http_exception_handler(request, exc)
66+
67+
68+
async def logging_request_validation_exception_handler(
69+
request: Request,
70+
exc: Exception,
71+
) -> Response:
72+
"""Log ``request_failed`` for validation errors, then delegate."""
73+
if not isinstance(exc, RequestValidationError):
74+
raise exc
75+
76+
started_at = getattr(request.state, "request_started_at", None)
77+
logger.warning(
78+
"request_failed",
79+
method=request.method,
80+
path=request.url.path,
81+
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
82+
error=exc.errors(),
83+
error_type=type(exc).__name__,
84+
duration_ms=(
85+
round((time.perf_counter() - started_at) * 1000, 2)
86+
if started_at is not None
87+
else None
88+
),
89+
)
90+
return await request_validation_exception_handler(request, exc)
91+
92+
3393
@asynccontextmanager
3494
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
3595
"""App lifespan for startup/shutdown housekeeping.
@@ -67,6 +127,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
67127
lifespan=lifespan,
68128
)
69129
app.add_middleware(LoggingMiddleware)
130+
app.add_exception_handler(StarletteHTTPException, logging_http_exception_handler)
131+
app.add_exception_handler(
132+
RequestValidationError,
133+
logging_request_validation_exception_handler,
134+
)
70135
app.include_router(api_v1_router, prefix=settings.API_V1_PREFIX)
71136

72137

src/fmu_settings_api/middleware/logging.py

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import time
66
from typing import TYPE_CHECKING
77

8-
from fastapi import HTTPException
98
from starlette.middleware.base import BaseHTTPMiddleware
109
from starlette.responses import JSONResponse
10+
from starlette.status import HTTP_400_BAD_REQUEST
1111

1212
from fmu_settings_api.logging import get_logger
1313

@@ -29,7 +29,8 @@ async def dispatch(
2929
call_next: Callable[[Request], Awaitable[Response]],
3030
) -> Response:
3131
"""Log request and response details."""
32-
start_time = time.time()
32+
start_time = time.perf_counter()
33+
request.state.request_started_at = start_time
3334

3435
logger.info(
3536
"request_started",
@@ -41,30 +42,19 @@ async def dispatch(
4142

4243
try:
4344
response = await call_next(request)
44-
duration = time.time() - start_time
45+
duration = time.perf_counter() - start_time
4546

46-
logger.info(
47-
"request_completed",
48-
method=request.method,
49-
path=request.url.path,
50-
status_code=response.status_code,
51-
duration_ms=round(duration * 1000, 2),
52-
)
47+
if response.status_code < HTTP_400_BAD_REQUEST:
48+
logger.info(
49+
"request_completed",
50+
method=request.method,
51+
path=request.url.path,
52+
status_code=response.status_code,
53+
duration_ms=round(duration * 1000, 2),
54+
)
5355
return response
54-
except HTTPException as e:
55-
duration = time.time() - start_time
56-
logger.warning(
57-
"request_failed",
58-
method=request.method,
59-
path=request.url.path,
60-
status_code=e.status_code,
61-
error=e.detail,
62-
error_type=type(e).__name__,
63-
duration_ms=round(duration * 1000, 2),
64-
)
65-
raise
6656
except Exception as e:
67-
duration = time.time() - start_time
57+
duration = time.perf_counter() - start_time
6858
logger.exception(
6959
"request_failed",
7060
method=request.method,

tests/test_main.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@
33
from datetime import UTC, datetime
44
from types import SimpleNamespace
55
from typing import Any, cast
6-
from unittest.mock import MagicMock
6+
from unittest.mock import MagicMock, patch
77

8-
from fastapi import status
8+
from fastapi import FastAPI, HTTPException, status
9+
from fastapi.exceptions import RequestValidationError
910
from fastapi.testclient import TestClient
11+
from starlette.exceptions import HTTPException as StarletteHTTPException
1012

11-
from fmu_settings_api.__main__ import app
13+
from fmu_settings_api.__main__ import (
14+
app,
15+
logging_http_exception_handler,
16+
logging_request_validation_exception_handler,
17+
)
18+
from fmu_settings_api.middleware.logging import LoggingMiddleware
1219
from fmu_settings_api.models import Ok
1320
from fmu_settings_api.session import (
1421
AccessTokens,
@@ -67,3 +74,78 @@ def test_shutdown_releases_project_lock() -> None:
6774
lock.release.assert_called_once()
6875
finally:
6976
session_manager.storage = original_storage
77+
78+
79+
def test_http_exception_logs_request_failed_details() -> None:
80+
"""Ensure HTTPException emits a detailed request_failed log entry."""
81+
test_app = FastAPI()
82+
test_app.add_middleware(LoggingMiddleware)
83+
test_app.add_exception_handler(
84+
StarletteHTTPException,
85+
logging_http_exception_handler,
86+
)
87+
test_app.add_exception_handler(
88+
RequestValidationError,
89+
logging_request_validation_exception_handler,
90+
)
91+
92+
@test_app.get("/test")
93+
async def failing_route() -> None:
94+
raise HTTPException(status_code=401, detail="Not authorized")
95+
96+
with patch("fmu_settings_api.__main__.logger") as mock_logger:
97+
with TestClient(test_app) as local_client:
98+
response = local_client.get("/test")
99+
100+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
101+
102+
warning_calls = [
103+
call
104+
for call in mock_logger.warning.call_args_list
105+
if call[0][0] == "request_failed"
106+
]
107+
assert len(warning_calls) > 0
108+
log_data = warning_calls[0][1]
109+
assert log_data["status_code"] == status.HTTP_401_UNAUTHORIZED
110+
assert log_data["error"] == "Not authorized"
111+
assert log_data["error_type"] == "HTTPException"
112+
assert "duration_ms" in log_data
113+
assert log_data["duration_ms"] >= 0
114+
115+
116+
def test_validation_exception_logs_request_failed_details() -> None:
117+
"""Ensure validation errors emit a detailed request_failed log entry."""
118+
test_app = FastAPI()
119+
test_app.add_middleware(LoggingMiddleware)
120+
test_app.add_exception_handler(
121+
StarletteHTTPException,
122+
logging_http_exception_handler,
123+
)
124+
test_app.add_exception_handler(
125+
RequestValidationError,
126+
logging_request_validation_exception_handler,
127+
)
128+
129+
@test_app.get("/test")
130+
async def validation_route(limit: int) -> dict[str, int]:
131+
return {"limit": limit}
132+
133+
with patch("fmu_settings_api.__main__.logger") as mock_logger:
134+
with TestClient(test_app) as local_client:
135+
response = local_client.get("/test?limit=bad")
136+
137+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
138+
139+
warning_calls = [
140+
call
141+
for call in mock_logger.warning.call_args_list
142+
if call[0][0] == "request_failed"
143+
]
144+
assert len(warning_calls) > 0
145+
log_data = warning_calls[0][1]
146+
assert log_data["status_code"] == status.HTTP_422_UNPROCESSABLE_CONTENT
147+
assert isinstance(log_data["error"], list)
148+
assert len(log_data["error"]) > 0
149+
assert log_data["error_type"] == "RequestValidationError"
150+
assert "duration_ms" in log_data
151+
assert log_data["duration_ms"] >= 0

tests/test_middleware_logging.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import contextlib
44
from unittest.mock import patch
55

6-
from fastapi import FastAPI, status
6+
from fastapi import FastAPI, HTTPException, status
77
from fastapi.testclient import TestClient
88
from starlette.responses import JSONResponse
99

@@ -163,6 +163,38 @@ async def test_route() -> JSONResponse:
163163
assert request_completed_call[0][1]["status_code"] == status.HTTP_201_CREATED
164164

165165

166+
def test_logging_middleware_skips_request_completed_for_http_errors() -> None:
167+
"""Test middleware only logs request_completed for responses with status < 400."""
168+
app = FastAPI()
169+
app.add_middleware(LoggingMiddleware)
170+
171+
@app.get("/test")
172+
async def test_route() -> None:
173+
raise HTTPException(status_code=422, detail="Invalid configuration")
174+
175+
with patch("fmu_settings_api.middleware.logging.logger") as mock_logger:
176+
with TestClient(app) as client:
177+
response = client.get("/test")
178+
179+
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
180+
181+
completed_calls = [
182+
call
183+
for call in mock_logger.info.call_args_list
184+
if call[0][0] == "request_completed"
185+
]
186+
assert len(completed_calls) == 0
187+
188+
# HTTPException handling is owned by app-level exception handlers.
189+
# Middleware should not emit request_failed here, to avoid duplicate logs.
190+
failed_calls = [
191+
call
192+
for call in mock_logger.warning.call_args_list
193+
if call[0][0] == "request_failed"
194+
]
195+
assert len(failed_calls) == 0
196+
197+
166198
def test_logging_middleware_logs_error_details() -> None:
167199
"""Test middleware logs error type and message on failure."""
168200
app = FastAPI()

0 commit comments

Comments
 (0)