Skip to content

Commit 4cf5278

Browse files
pcrespovmrnicegyu11
authored andcommitted
🐛🎨 Fixes unhandled config error and new log helpers for better troubleshotting (ITISFoundation#6432)
1 parent d34ac48 commit 4cf5278

File tree

9 files changed

+185
-37
lines changed

9 files changed

+185
-37
lines changed

packages/models-library/src/models_library/errors_classes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ def _get_full_class_name(cls) -> str:
3030
)
3131
]
3232
return ".".join(reversed(relevant_classes))
33+
34+
def error_context(self):
35+
"""Returns context in which error occurred and stored within the exception"""
36+
return dict(**self.__dict__)

packages/models-library/tests/test_errors_classes.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,14 @@ class MyErrorAfter(OsparcErrorMixin, ValueError):
148148
msg_template = "{value} and {missing}"
149149

150150
assert str(MyErrorAfter(value=42)) == "42 and 'missing=?'"
151+
152+
153+
def test_exception_context():
154+
class MyError(OsparcErrorMixin, ValueError):
155+
msg_template = "{value} and {missing}"
156+
157+
exc = MyError(value=42, missing="foo", extra="bar")
158+
assert exc.error_context() == {"value": 42, "missing": "foo", "extra": "bar"}
159+
160+
exc = MyError(value=42)
161+
assert exc.error_context() == {"value": 42}

packages/service-library/src/servicelib/logging_utils.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
from pathlib import Path
1717
from typing import Any, TypeAlias, TypedDict, TypeVar
1818

19+
from models_library.utils.json_serialization import json_dumps
20+
21+
from .error_codes import ErrorCodeStr
1922
from .utils_secrets import mask_sensitive_data
2023

2124
_logger = logging.getLogger(__name__)
@@ -320,20 +323,58 @@ def log_catch(logger: logging.Logger, *, reraise: bool = True) -> Iterator[None]
320323

321324
class LogExtra(TypedDict, total=False):
322325
log_uid: str
326+
log_oec: str
323327

324328

325329
LogLevelInt: TypeAlias = int
326330
LogMessageStr: TypeAlias = str
327331

328332

329-
def get_log_record_extra(*, user_id: int | str | None = None) -> LogExtra | None:
333+
def get_log_record_extra(
334+
*,
335+
user_id: int | str | None = None,
336+
error_code: str | None = None,
337+
) -> LogExtra | None:
330338
extra: LogExtra = {}
339+
331340
if user_id:
332341
assert int(user_id) > 0 # nosec
333342
extra["log_uid"] = f"{user_id}"
343+
if error_code:
344+
extra["log_oec"] = error_code
345+
334346
return extra or None
335347

336348

349+
def create_troubleshotting_log_message(
350+
message_to_user: str,
351+
error: BaseException,
352+
error_code: ErrorCodeStr,
353+
error_context: dict[str, Any] | None = None,
354+
tip: str | None = None,
355+
) -> str:
356+
"""Create a formatted message for _logger.exception(...)
357+
358+
Arguments:
359+
message_to_user -- A user-friendly message to be displayed on the front-end explaining the issue in simple terms.
360+
error -- the instance of the handled exception
361+
error_code -- A unique error code (e.g., OEC or osparc-specific) to identify the type or source of the error for easier tracking.
362+
error_context -- Additional context surrounding the exception, such as environment variables or function-specific data. This can be derived from exc.error_context() (relevant when using the OsparcErrorMixin)
363+
tip -- Helpful suggestions or possible solutions explaining why the error may have occurred and how it could potentially be resolved
364+
"""
365+
debug_data = json_dumps(
366+
{
367+
"exception_details": f"{error}",
368+
"error_code": error_code,
369+
"context": error_context,
370+
"tip": tip,
371+
},
372+
indent=1,
373+
)
374+
375+
return f"{message_to_user}.\n{debug_data}"
376+
377+
337378
def _un_capitalize(s: str) -> str:
338379
return s[:1].lower() + s[1:] if s else ""
339380

packages/service-library/tests/test_error_codes.py

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,50 @@
55

66
import logging
77

8+
import pytest
89
from servicelib.error_codes import create_error_code, parse_error_code
910

1011
logger = logging.getLogger(__name__)
1112

1213

13-
def test_error_code_use_case(caplog):
14+
def test_error_code_use_case(caplog: pytest.LogCaptureFixture):
1415
"""use case for error-codes"""
15-
try:
16+
with pytest.raises(RuntimeError) as exc_info:
1617
raise RuntimeError("Something unexpected went wrong")
17-
except Exception as err:
18-
# 1. Unexpected ERROR
1918

20-
# 2. create error-code
21-
error_code = create_error_code(err)
19+
# 1. Unexpected ERROR
20+
err = exc_info.value
2221

23-
# 3. log all details in service
24-
caplog.clear()
22+
# 2. create error-code
23+
error_code = create_error_code(err)
2524

26-
# Can add a formatter that prefix error-codes
27-
syslog = logging.StreamHandler()
28-
syslog.setFormatter(
29-
logging.Formatter("%(asctime)s %(error_code)s : %(message)s")
30-
)
31-
logger.addHandler(syslog)
25+
# 3. log all details in service
26+
caplog.clear()
3227

33-
logger.error("Fake Unexpected error", extra={"error_code": error_code})
28+
# Can add a formatter that prefix error-codes
29+
syslog = logging.StreamHandler()
30+
syslog.setFormatter(logging.Formatter("%(asctime)s %(error_code)s : %(message)s"))
31+
logger.addHandler(syslog)
3432

35-
# logs something like E.g. 2022-07-06 14:31:13,432 OEC:140350117529856 : Fake Unexpected error
36-
assert parse_error_code(
37-
f"2022-07-06 14:31:13,432 {error_code} : Fake Unexpected error"
38-
) == {
39-
error_code,
40-
}
33+
logger.exception("Fake Unexpected error", extra={"error_code": error_code})
4134

42-
assert caplog.records[0].error_code == error_code
43-
assert caplog.records[0]
35+
# logs something like E.g. 2022-07-06 14:31:13,432 OEC:140350117529856 : Fake Unexpected error
36+
assert parse_error_code(
37+
f"2022-07-06 14:31:13,432 {error_code} : Fake Unexpected error"
38+
) == {
39+
error_code,
40+
}
4441

45-
logger.error("Fake without error_code")
42+
assert caplog.records[0].error_code == error_code
43+
assert caplog.records[0]
4644

47-
# 4. inform user (e.g. with new error or sending message)
48-
user_message = (
49-
f"This is a user-friendly message to inform about an error. [{error_code}]"
50-
)
45+
logger.exception("Fake without error_code")
5146

52-
assert parse_error_code(user_message) == {
53-
error_code,
54-
}
47+
# 4. inform user (e.g. with new error or sending message)
48+
user_message = (
49+
f"This is a user-friendly message to inform about an error. [{error_code}]"
50+
)
51+
52+
assert parse_error_code(user_message) == {
53+
error_code,
54+
}

packages/service-library/tests/test_logging_utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66

77
import pytest
88
from faker import Faker
9+
from models_library.errors_classes import OsparcErrorMixin
10+
from servicelib.error_codes import create_error_code
911
from servicelib.logging_utils import (
1012
LogExtra,
1113
LogLevelInt,
1214
LogMessageStr,
15+
create_troubleshotting_log_message,
16+
get_log_record_extra,
1317
guess_message_log_level,
1418
log_context,
1519
log_decorator,
@@ -377,3 +381,42 @@ def test_set_parent_module_log_level_(caplog: pytest.LogCaptureFixture):
377381

378382
assert "parent warning" in caplog.text
379383
assert "child warning" in caplog.text
384+
385+
386+
def test_create_troubleshotting_log_message(caplog: pytest.LogCaptureFixture):
387+
class MyError(OsparcErrorMixin, RuntimeError):
388+
msg_template = "My error {user_id}"
389+
390+
with pytest.raises(MyError) as exc_info:
391+
raise MyError(user_id=123, product_name="foo")
392+
393+
exc = exc_info.value
394+
error_code = create_error_code(exc)
395+
log_msg = create_troubleshotting_log_message(
396+
f"Nice message to user [{error_code}]",
397+
exc,
398+
error_code=error_code,
399+
error_context=exc.error_context(),
400+
tip="This is a test error",
401+
)
402+
403+
with caplog.at_level(logging.WARNING):
404+
root_logger = logging.getLogger()
405+
root_logger.exception(
406+
log_msg, extra=get_log_record_extra(error_code=error_code)
407+
)
408+
409+
# ERROR root:test_logging_utils.py:417 Nice message to user [OEC:126055703573984].
410+
# {
411+
# "exception_details": "My error 123",
412+
# "error_code": "OEC:126055703573984",
413+
# "context": {
414+
# "user_id": 123,
415+
# "product_name": "foo"
416+
# },
417+
# "tip": "This is a test error"
418+
# }
419+
420+
assert error_code in caplog.text
421+
assert "user_id" in caplog.text
422+
assert "product_name" in caplog.text
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from typing import Final
2+
3+
FMSG_MISSING_CONFIG_WITH_OEC: Final[str] = (
4+
"The product is not ready for use until the configuration is fully completed. "
5+
"Please wait and try again. "
6+
"If the issue continues, contact support with error code: {error_code}."
7+
)

services/web/server/src/simcore_service_webserver/users/_handlers.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
parse_request_query_parameters_as,
1010
)
1111
from servicelib.aiohttp.typing_extension import Handler
12+
from servicelib.error_codes import create_error_code
13+
from servicelib.logging_utils import (
14+
create_troubleshotting_log_message,
15+
get_log_record_extra,
16+
)
1217
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
1318
from servicelib.request_keys import RQT_USERID_KEY
1419
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
@@ -19,8 +24,13 @@
1924
from ..security.decorators import permission_required
2025
from ..utils_aiohttp import envelope_json_response
2126
from . import _api, api
27+
from ._constants import FMSG_MISSING_CONFIG_WITH_OEC
2228
from ._schemas import PreUserProfile
23-
from .exceptions import AlreadyPreRegisteredError, UserNotFoundError
29+
from .exceptions import (
30+
AlreadyPreRegisteredError,
31+
MissingGroupExtraPropertiesForProductError,
32+
UserNotFoundError,
33+
)
2434
from .schemas import ProfileGet, ProfileUpdate
2535

2636
_logger = logging.getLogger(__name__)
@@ -42,6 +52,25 @@ async def wrapper(request: web.Request) -> web.StreamResponse:
4252

4353
except UserNotFoundError as exc:
4454
raise web.HTTPNotFound(reason=f"{exc}") from exc
55+
except MissingGroupExtraPropertiesForProductError as exc:
56+
error_code = create_error_code(exc)
57+
frontend_msg = FMSG_MISSING_CONFIG_WITH_OEC.format(error_code)
58+
log_msg = create_troubleshotting_log_message(
59+
message_to_user=frontend_msg,
60+
error=exc,
61+
error_code=error_code,
62+
error_context=exc.error_context(),
63+
tip="Row in `groups_extra_properties` for this product is missing.",
64+
)
65+
66+
_logger.exception(
67+
log_msg,
68+
extra=get_log_record_extra(
69+
error_code=error_code,
70+
user_id=exc.error_context().get("user_id", None),
71+
),
72+
)
73+
raise web.HTTPServiceUnavailable(reason=frontend_msg) from exc
4574

4675
return wrapper
4776

services/web/server/src/simcore_service_webserver/users/api.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
from models_library.users import GroupID, UserID
1919
from pydantic import EmailStr, ValidationError, parse_obj_as
2020
from simcore_postgres_database.models.users import UserRole
21+
from simcore_postgres_database.utils_groups_extra_properties import (
22+
GroupExtraPropertiesNotFoundError,
23+
)
2124

2225
from ..db.models import GroupType, groups, user_to_groups, users
2326
from ..db.plugin import get_database_engine
@@ -27,7 +30,7 @@
2730
from . import _db
2831
from ._api import get_user_credentials, get_user_invoice_address, set_user_as_deleted
2932
from ._preferences_api import get_frontend_user_preferences_aggregation
30-
from .exceptions import UserNotFoundError
33+
from .exceptions import MissingGroupExtraPropertiesForProductError, UserNotFoundError
3134
from .schemas import ProfileGet, ProfileUpdate
3235

3336
_logger = logging.getLogger(__name__)
@@ -45,6 +48,7 @@ async def get_user_profile(
4548
) -> ProfileGet:
4649
"""
4750
:raises UserNotFoundError:
51+
:raises MissingGroupExtraPropertiesForProductError: when product is not properly configured
4852
"""
4953

5054
engine = get_database_engine(app)
@@ -107,9 +111,14 @@ async def get_user_profile(
107111
if not user_profile:
108112
raise UserNotFoundError(uid=user_id)
109113

110-
preferences = await get_frontend_user_preferences_aggregation(
111-
app, user_id=user_id, product_name=product_name
112-
)
114+
try:
115+
preferences = await get_frontend_user_preferences_aggregation(
116+
app, user_id=user_id, product_name=product_name
117+
)
118+
except GroupExtraPropertiesNotFoundError as err:
119+
raise MissingGroupExtraPropertiesForProductError(
120+
user_id=user_id, product_name=product_name
121+
) from err
113122

114123
# NOTE: expirationDate null is not handled properly in front-end.
115124
# https://github.com/ITISFoundation/osparc-simcore/issues/5244

services/web/server/src/simcore_service_webserver/users/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ class AlreadyPreRegisteredError(UsersBaseError):
5050
class BillingDetailsNotFoundError(UsersBaseError):
5151
# NOTE: this is for internal log and should not be transmitted to the final user
5252
msg_template = "Billing details are missing for user_id={user_id}. TIP: Check whether this user is pre-registered"
53+
54+
55+
class MissingGroupExtraPropertiesForProductError(UsersBaseError):
56+
msg_template = "Missing group_extra_property for product_name={product_name}"

0 commit comments

Comments
 (0)