Skip to content

Commit 45ffe4e

Browse files
authored
🎨 Unique EOC for deduplication purposes (ITISFoundation#7364)
1 parent 772ef54 commit 45ffe4e

File tree

3 files changed

+130
-26
lines changed

3 files changed

+130
-26
lines changed
Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
""" osparc ERROR CODES (OEC)
1+
"""osparc ERROR CODES (OEC)
22
Unique identifier of an exception instance
33
Intended to report a user about unexpected errors.
44
Unexpected exceptions can be traced by matching the
@@ -7,25 +7,79 @@
77
SEE test_error_codes for some use cases
88
"""
99

10+
import hashlib
1011
import re
11-
from typing import TYPE_CHECKING, Annotated
12+
import traceback
13+
from datetime import UTC, datetime
14+
from typing import Annotated, Final, TypeAlias
1215

1316
from pydantic import StringConstraints, TypeAdapter
1417

15-
_LABEL = "OEC:{}"
16-
_PATTERN = r"OEC:\d+"
18+
_LABEL = "OEC:{fingerprint}-{timestamp}"
1719

18-
if TYPE_CHECKING:
19-
ErrorCodeStr = str
20-
else:
21-
ErrorCodeStr = Annotated[
22-
str, StringConstraints(strip_whitespace=True, pattern=_PATTERN)
23-
]
20+
_LEN = 12 # chars (~48 bits)
21+
_NAMED_PATTERN = re.compile(
22+
r"OEC:(?P<fingerprint>[a-fA-F0-9]{12})-(?P<timestamp>\d{13,14})"
23+
# NOTE: timestamp limits: 13 digits (from 2001), 14 digits (good for ~500+ years)
24+
)
25+
_PATTERN = re.compile(r"OEC:[a-fA-F0-9]{12}-\d{13,14}")
26+
27+
28+
ErrorCodeStr: TypeAlias = Annotated[
29+
str, StringConstraints(strip_whitespace=True, pattern=_NAMED_PATTERN)
30+
]
31+
32+
33+
def _create_fingerprint(exc: BaseException) -> str:
34+
"""
35+
Unique error fingerprint of the **traceback** for deduplication purposes
36+
"""
37+
tb = traceback.extract_tb(exc.__traceback__)
38+
frame_sigs = [f"{frame.name}:{frame.lineno}" for frame in tb]
39+
fingerprint = f"{type(exc).__name__}|" + "|".join(frame_sigs)
40+
# E.g. ZeroDivisionError|foo:23|main:10
41+
return hashlib.sha256(fingerprint.encode()).hexdigest()[:_LEN]
42+
43+
44+
_SECS_TO_MILISECS: Final[int] = 1000 # ms
45+
46+
47+
def _create_timestamp() -> int:
48+
"""Timestamp as milliseconds since epoch
49+
NOTE: this reduces the precission to milliseconds but it is good enough for our purpose
50+
"""
51+
ts = datetime.now(UTC).timestamp() * _SECS_TO_MILISECS
52+
return int(ts)
2453

2554

2655
def create_error_code(exception: BaseException) -> ErrorCodeStr:
27-
return TypeAdapter(ErrorCodeStr).validate_python(_LABEL.format(id(exception)))
56+
"""
57+
Generates a unique error code for the given exception.
58+
59+
The error code follows the format: `OEC:{traceback}-{timestamp}`.
60+
This code is intended to be shared with the front-end as a `SupportID`
61+
for debugging and support purposes.
62+
"""
63+
return TypeAdapter(ErrorCodeStr).validate_python(
64+
_LABEL.format(
65+
fingerprint=_create_fingerprint(exception),
66+
timestamp=_create_timestamp(),
67+
)
68+
)
69+
70+
71+
def parse_error_codes(obj) -> list[ErrorCodeStr]:
72+
return TypeAdapter(list[ErrorCodeStr]).validate_python(_PATTERN.findall(f"{obj}"))
2873

2974

30-
def parse_error_code(obj) -> set[ErrorCodeStr]:
31-
return set(re.findall(_PATTERN, f"{obj}"))
75+
def parse_error_code_parts(oec: ErrorCodeStr) -> tuple[str, datetime]:
76+
"""Returns traceback-fingerprint and timestamp from `OEC:{traceback}-{timestamp}`"""
77+
match = _NAMED_PATTERN.match(oec)
78+
if not match:
79+
msg = f"Invalid error code format: {oec}"
80+
raise ValueError(msg)
81+
fingerprint = match.group("fingerprint")
82+
timestamp = datetime.fromtimestamp(
83+
float(match.group("timestamp")) / _SECS_TO_MILISECS, tz=UTC
84+
)
85+
return fingerprint, timestamp

‎packages/common-library/tests/test_error_codes.py‎

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,68 @@
44
# pylint: disable=unused-variable
55

66
import logging
7+
import time
78

89
import pytest
9-
from common_library.error_codes import create_error_code, parse_error_code
10+
from common_library.error_codes import (
11+
create_error_code,
12+
parse_error_code_parts,
13+
parse_error_codes,
14+
)
1015

1116
logger = logging.getLogger(__name__)
1217

1318

14-
def test_error_code_use_case(caplog: pytest.LogCaptureFixture):
15-
"""use case for error-codes"""
19+
def _level_three(v):
20+
msg = f"An error occurred in level three with {v}"
21+
raise RuntimeError(msg)
22+
23+
24+
def _level_two(v):
25+
_level_three(v)
26+
27+
28+
def _level_one(v=None):
29+
_level_two(v)
30+
31+
32+
def test_exception_fingerprint_consistency():
33+
error_codes = []
34+
35+
for v in range(2):
36+
# emulates different runs of the same function (e.g. different sessions)
37+
try:
38+
_level_one(v) # same even if different value!
39+
except Exception as err:
40+
time.sleep(1)
41+
error_code = create_error_code(err)
42+
error_codes.append(error_code)
43+
44+
fingerprints, timestamps = list(
45+
zip(
46+
*[parse_error_code_parts(error_code) for error_code in error_codes],
47+
strict=True,
48+
)
49+
)
50+
51+
assert fingerprints[0] == fingerprints[1]
52+
assert timestamps[0] < timestamps[1]
53+
54+
try:
55+
# Same function but different location
56+
_level_one(0)
57+
except Exception as e2:
58+
time.sleep(1)
59+
error_code_2 = create_error_code(e2)
60+
fingerprint_2, timestamp_2 = parse_error_code_parts(error_code_2)
61+
62+
assert fingerprints[0] != fingerprint_2
63+
assert timestamps[1] < timestamp_2
64+
65+
66+
def test_create_log_and_parse_error_code(caplog: pytest.LogCaptureFixture):
1667
with pytest.raises(RuntimeError) as exc_info:
17-
raise RuntimeError("Something unexpected went wrong")
68+
_level_one()
1869

1970
# 1. Unexpected ERROR
2071
err = exc_info.value
@@ -33,11 +84,11 @@ def test_error_code_use_case(caplog: pytest.LogCaptureFixture):
3384
logger.exception("Fake Unexpected error", extra={"error_code": error_code})
3485

3586
# logs something like E.g. 2022-07-06 14:31:13,432 OEC:140350117529856 : Fake Unexpected error
36-
assert parse_error_code(
87+
assert parse_error_codes(
3788
f"2022-07-06 14:31:13,432 {error_code} : Fake Unexpected error"
38-
) == {
89+
) == [
3990
error_code,
40-
}
91+
]
4192

4293
assert caplog.records[0].error_code == error_code
4394
assert caplog.records[0]
@@ -49,6 +100,6 @@ def test_error_code_use_case(caplog: pytest.LogCaptureFixture):
49100
f"This is a user-friendly message to inform about an error. [{error_code}]"
50101
)
51102

52-
assert parse_error_code(user_message) == {
103+
assert parse_error_codes(user_message) == [
53104
error_code,
54-
}
105+
]

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pytest
1414
from aiohttp import web
1515
from aiohttp.test_utils import TestClient
16-
from common_library.error_codes import parse_error_code
16+
from common_library.error_codes import parse_error_codes
1717
from common_library.json_serialization import json_dumps
1818
from servicelib.aiohttp import status
1919
from servicelib.aiohttp.rest_middlewares import (
@@ -30,8 +30,7 @@ class Data:
3030
y: str = "foo"
3131

3232

33-
class SomeUnexpectedError(Exception):
34-
...
33+
class SomeUnexpectedError(Exception): ...
3534

3635

3736
class Handlers:
@@ -237,7 +236,7 @@ async def test_raised_unhandled_exception(
237236

238237
# user friendly message with OEC reference
239238
assert "OEC" in error["message"]
240-
parsed_oec = parse_error_code(error["message"]).pop()
239+
parsed_oec = parse_error_codes(error["message"]).pop()
241240
assert (
242241
_FMSG_INTERNAL_ERROR_USER_FRIENDLY_WITH_OEC.format(error_code=parsed_oec)
243242
== error["message"]

0 commit comments

Comments
 (0)