Skip to content

Commit d0e66f1

Browse files
authored
🎨 Enhanced error handling and troubleshooting logs helpers (#6531)
1 parent 33237ba commit d0e66f1

File tree

27 files changed

+456
-277
lines changed

27 files changed

+456
-277
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from pydantic.errors import PydanticErrorMixin
22

3+
from .error_codes import create_error_code
4+
35

46
class _DefaultDict(dict):
57
def __missing__(self, key):
@@ -34,3 +36,7 @@ def _get_full_class_name(cls) -> str:
3436
def error_context(self):
3537
"""Returns context in which error occurred and stored within the exception"""
3638
return dict(**self.__dict__)
39+
40+
def error_code(self) -> str:
41+
assert isinstance(self, Exception), "subclass must be exception" # nosec
42+
return create_error_code(self)

packages/service-library/tests/test_error_codes.py renamed to packages/models-library/tests/test_error_codes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77

88
import pytest
9-
from servicelib.error_codes import create_error_code, parse_error_code
9+
from models_library.error_codes import create_error_code, parse_error_code
1010

1111
logger = logging.getLogger(__name__)
1212

packages/service-library/src/servicelib/aiohttp/rest_middlewares.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
from aiohttp import web
1313
from aiohttp.web_request import Request
1414
from aiohttp.web_response import StreamResponse
15-
from models_library.errors_classes import OsparcErrorMixin
15+
from models_library.error_codes import create_error_code
1616
from models_library.utils.json_serialization import json_dumps
17-
from servicelib.error_codes import create_error_code
1817

19-
from ..logging_utils import create_troubleshotting_log_message, get_log_record_extra
18+
from ..logging_errors import create_troubleshotting_log_kwargs
2019
from ..mimetype_constants import MIMETYPE_APPLICATION_JSON
2120
from ..utils import is_production_environ
2221
from .rest_models import ErrorItemType, ErrorType, LogMessageType
@@ -59,31 +58,23 @@ def _process_and_raise_unexpected_error(request: web.BaseRequest, err: Exception
5958
"request.method": f"{request.method}",
6059
"request.path": f"{request.path}",
6160
}
62-
if isinstance(err, OsparcErrorMixin):
63-
error_context.update(err.error_context())
6461

65-
frontend_msg = _FMSG_INTERNAL_ERROR_USER_FRIENDLY_WITH_OEC.format(
62+
user_error_msg = _FMSG_INTERNAL_ERROR_USER_FRIENDLY_WITH_OEC.format(
6663
error_code=error_code
6764
)
68-
log_msg = create_troubleshotting_log_message(
69-
message_to_user=frontend_msg,
70-
error=err,
71-
error_code=error_code,
72-
error_context=error_context,
73-
)
74-
7565
http_error = create_http_error(
7666
err,
77-
frontend_msg,
67+
user_error_msg,
7868
web.HTTPInternalServerError,
7969
skip_internal_error_details=_is_prod,
8070
)
8171
_logger.exception(
82-
log_msg,
83-
extra=get_log_record_extra(
72+
**create_troubleshotting_log_kwargs(
73+
user_error_msg,
74+
error=err,
75+
error_context=error_context,
8476
error_code=error_code,
85-
user_id=error_context.get("user_id"),
86-
),
77+
)
8778
)
8879
raise http_error
8980

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import logging
2+
from pprint import pformat
3+
from typing import Any, TypedDict
4+
5+
from models_library.error_codes import ErrorCodeStr
6+
from models_library.errors_classes import OsparcErrorMixin
7+
8+
from .logging_utils import LogExtra, get_log_record_extra
9+
10+
_logger = logging.getLogger(__name__)
11+
12+
13+
def create_troubleshotting_log_message(
14+
user_error_msg: str,
15+
*,
16+
error: BaseException,
17+
error_code: ErrorCodeStr | None = None,
18+
error_context: dict[str, Any] | None = None,
19+
tip: str | None = None,
20+
) -> str:
21+
"""Create a formatted message for _logger.exception(...)
22+
23+
Arguments:
24+
user_error_msg -- A user-friendly message to be displayed on the front-end explaining the issue in simple terms.
25+
error -- the instance of the handled exception
26+
error_code -- A unique error code (e.g., OEC or osparc-specific) to identify the type or source of the error for easier tracking.
27+
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)
28+
tip -- Helpful suggestions or possible solutions explaining why the error may have occurred and how it could potentially be resolved
29+
"""
30+
debug_data = pformat(
31+
{
32+
"exception_type": f"{type(error)}",
33+
"exception_details": f"{error}",
34+
"error_code": error_code,
35+
"context": pformat(error_context, indent=1),
36+
"tip": tip,
37+
},
38+
indent=1,
39+
)
40+
41+
return f"{user_error_msg}.\n{debug_data}"
42+
43+
44+
class LogKwargs(TypedDict):
45+
msg: str
46+
extra: LogExtra | None
47+
48+
49+
def create_troubleshotting_log_kwargs(
50+
user_error_msg: str,
51+
*,
52+
error: BaseException,
53+
error_code: ErrorCodeStr | None = None,
54+
error_context: dict[str, Any] | None = None,
55+
tip: str | None = None,
56+
) -> LogKwargs:
57+
"""
58+
Creates a dictionary of logging arguments to be used with _log.exception for troubleshooting purposes.
59+
60+
Usage:
61+
62+
try:
63+
...
64+
except MyException as exc
65+
_logger.exception(
66+
**create_troubleshotting_log_kwargs(
67+
user_error_msg=frontend_msg,
68+
exception=exc,
69+
tip="Check row in `groups_extra_properties` for this product. It might be missing.",
70+
)
71+
)
72+
73+
"""
74+
# error-context
75+
context = error_context or {}
76+
if isinstance(error, OsparcErrorMixin):
77+
context.update(error.error_context())
78+
79+
# compose as log message
80+
log_msg = create_troubleshotting_log_message(
81+
user_error_msg,
82+
error=error,
83+
error_code=error_code,
84+
error_context=context,
85+
tip=tip,
86+
)
87+
88+
return {
89+
"msg": log_msg,
90+
"extra": get_log_record_extra(
91+
error_code=error_code,
92+
user_id=context.get("user_id", None),
93+
),
94+
}

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

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@
1414
from datetime import datetime
1515
from inspect import getframeinfo, stack
1616
from pathlib import Path
17-
from pprint import pformat
18-
from typing import Any, TypeAlias, TypedDict, TypeVar
17+
from typing import Any, NotRequired, TypeAlias, TypedDict, TypeVar
1918

20-
from .error_codes import ErrorCodeStr
2119
from .utils_secrets import mask_sensitive_data
2220

2321
_logger = logging.getLogger(__name__)
@@ -320,9 +318,9 @@ def log_catch(logger: logging.Logger, *, reraise: bool = True) -> Iterator[None]
320318
raise exc from exc
321319

322320

323-
class LogExtra(TypedDict, total=False):
324-
log_uid: str
325-
log_oec: str
321+
class LogExtra(TypedDict):
322+
log_uid: NotRequired[str]
323+
log_oec: NotRequired[str]
326324

327325

328326
LogLevelInt: TypeAlias = int
@@ -345,35 +343,6 @@ def get_log_record_extra(
345343
return extra or None
346344

347345

348-
def create_troubleshotting_log_message(
349-
message_to_user: str,
350-
error: BaseException | None,
351-
error_code: ErrorCodeStr,
352-
error_context: dict[str, Any] | None = None,
353-
tip: str | None = None,
354-
) -> str:
355-
"""Create a formatted message for _logger.exception(...)
356-
357-
Arguments:
358-
message_to_user -- A user-friendly message to be displayed on the front-end explaining the issue in simple terms.
359-
error -- the instance of the handled exception
360-
error_code -- A unique error code (e.g., OEC or osparc-specific) to identify the type or source of the error for easier tracking.
361-
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)
362-
tip -- Helpful suggestions or possible solutions explaining why the error may have occurred and how it could potentially be resolved
363-
"""
364-
debug_data = pformat(
365-
{
366-
"exception_details": f"{error}",
367-
"error_code": error_code,
368-
"context": pformat(error_context, indent=1),
369-
"tip": tip,
370-
},
371-
indent=1,
372-
)
373-
374-
return f"{message_to_user}.\n{debug_data}"
375-
376-
377346
def _un_capitalize(s: str) -> str:
378347
return s[:1].lower() + s[1:] if s else ""
379348

packages/service-library/tests/aiohttp/test_rest_middlewares.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import pytest
1414
from aiohttp import web
1515
from aiohttp.test_utils import TestClient
16+
from models_library.error_codes import parse_error_code
1617
from models_library.utils.json_serialization import json_dumps
1718
from servicelib.aiohttp import status
1819
from servicelib.aiohttp.rest_middlewares import (
@@ -21,7 +22,6 @@
2122
error_middleware_factory,
2223
)
2324
from servicelib.aiohttp.rest_responses import is_enveloped, unwrap_envelope
24-
from servicelib.error_codes import parse_error_code
2525

2626

2727
@dataclass
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# pylint:disable=redefined-outer-name
2+
3+
import logging
4+
5+
import pytest
6+
from models_library.error_codes import create_error_code
7+
from models_library.errors_classes import OsparcErrorMixin
8+
from servicelib.logging_errors import (
9+
create_troubleshotting_log_kwargs,
10+
create_troubleshotting_log_message,
11+
)
12+
13+
14+
def test_create_troubleshotting_log_message(caplog: pytest.LogCaptureFixture):
15+
class MyError(OsparcErrorMixin, RuntimeError):
16+
msg_template = "My error {user_id}"
17+
18+
with pytest.raises(MyError) as exc_info:
19+
raise MyError(user_id=123, product_name="foo")
20+
21+
exc = exc_info.value
22+
error_code = create_error_code(exc)
23+
24+
assert exc.error_code() == error_code
25+
26+
msg = f"Nice message to user [{error_code}]"
27+
28+
log_msg = create_troubleshotting_log_message(
29+
msg,
30+
error=exc,
31+
error_code=error_code,
32+
error_context=exc.error_context(),
33+
tip="This is a test error",
34+
)
35+
36+
log_kwargs = create_troubleshotting_log_kwargs(
37+
msg,
38+
error=exc,
39+
error_code=error_code,
40+
tip="This is a test error",
41+
)
42+
43+
assert log_kwargs["msg"] == log_msg
44+
assert log_kwargs["extra"] is not None
45+
assert (
46+
# pylint: disable=unsubscriptable-object
47+
log_kwargs["extra"]["log_uid"]
48+
== "123"
49+
), "user_id is injected as extra from context"
50+
51+
with caplog.at_level(logging.WARNING):
52+
root_logger = logging.getLogger()
53+
root_logger.exception(**log_kwargs)
54+
55+
# ERROR root:test_logging_utils.py:417 Nice message to user [OEC:126055703573984].
56+
# {
57+
# "exception_details": "My error 123",
58+
# "error_code": "OEC:126055703573984",
59+
# "context": {
60+
# "user_id": 123,
61+
# "product_name": "foo"
62+
# },
63+
# "tip": "This is a test error"
64+
# }
65+
66+
assert error_code in caplog.text
67+
assert "user_id" in caplog.text
68+
assert "product_name" in caplog.text

packages/service-library/tests/test_logging_utils.py

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,10 @@
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
119
from servicelib.logging_utils import (
1210
LogExtra,
1311
LogLevelInt,
1412
LogMessageStr,
15-
create_troubleshotting_log_message,
16-
get_log_record_extra,
1713
guess_message_log_level,
1814
log_context,
1915
log_decorator,
@@ -381,42 +377,3 @@ def test_set_parent_module_log_level_(caplog: pytest.LogCaptureFixture):
381377

382378
assert "parent warning" in caplog.text
383379
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

0 commit comments

Comments
 (0)