Skip to content

Commit 7dc7ba2

Browse files
authored
Capture exception info for server errors (#19)
* WIP * WIP * Add separate type field * Add no cover comment * Truncate exception msg and traceback
1 parent dbe8174 commit 7dc7ba2

File tree

11 files changed

+253
-37
lines changed

11 files changed

+253
-37
lines changed

apitally/client/base.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import re
66
import threading
77
import time
8+
import traceback
89
from abc import ABC
910
from collections import Counter
1011
from dataclasses import dataclass
@@ -24,6 +25,8 @@
2425
SYNC_INTERVAL = 60
2526
INITIAL_SYNC_INTERVAL = 10
2627
INITIAL_SYNC_INTERVAL_DURATION = 3600
28+
MAX_EXCEPTION_MSG_LENGTH = 2048
29+
MAX_EXCEPTION_TRACEBACK_LENGTH = 65536
2730

2831
TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase")
2932

@@ -54,6 +57,7 @@ def __init__(self, client_id: str, env: str) -> None:
5457
self.instance_uuid = str(uuid4())
5558
self.request_counter = RequestCounter()
5659
self.validation_error_counter = ValidationErrorCounter()
60+
self.server_error_counter = ServerErrorCounter()
5761

5862
self._app_info_payload: Optional[Dict[str, Any]] = None
5963
self._app_info_sent = False
@@ -86,11 +90,13 @@ def get_info_payload(self, app_info: Dict[str, Any]) -> Dict[str, Any]:
8690
def get_requests_payload(self) -> Dict[str, Any]:
8791
requests = self.request_counter.get_and_reset_requests()
8892
validation_errors = self.validation_error_counter.get_and_reset_validation_errors()
93+
server_errors = self.server_error_counter.get_and_reset_server_errors()
8994
return {
9095
"instance_uuid": self.instance_uuid,
9196
"message_uuid": str(uuid4()),
9297
"requests": requests,
9398
"validation_errors": validation_errors,
99+
"server_errors": server_errors,
94100
}
95101

96102

@@ -222,3 +228,75 @@ def get_and_reset_validation_errors(self) -> List[Dict[str, Any]]:
222228
)
223229
self.error_counts.clear()
224230
return data
231+
232+
233+
@dataclass(frozen=True)
234+
class ServerError:
235+
consumer: Optional[str]
236+
method: str
237+
path: str
238+
type: str
239+
msg: str
240+
traceback: str
241+
242+
243+
class ServerErrorCounter:
244+
def __init__(self) -> None:
245+
self.error_counts: Counter[ServerError] = Counter()
246+
self._lock = threading.Lock()
247+
248+
def add_server_error(self, consumer: Optional[str], method: str, path: str, exception: BaseException) -> None:
249+
if not isinstance(exception, BaseException):
250+
return # pragma: no cover
251+
exception_type = type(exception)
252+
with self._lock:
253+
server_error = ServerError(
254+
consumer=consumer,
255+
method=method.upper(),
256+
path=path,
257+
type=f"{exception_type.__module__}.{exception_type.__qualname__}",
258+
msg=self._get_truncated_exception_msg(exception),
259+
traceback=self._get_truncated_exception_traceback(exception),
260+
)
261+
self.error_counts[server_error] += 1
262+
263+
def get_and_reset_server_errors(self) -> List[Dict[str, Any]]:
264+
data: List[Dict[str, Any]] = []
265+
with self._lock:
266+
for server_error, count in self.error_counts.items():
267+
data.append(
268+
{
269+
"consumer": server_error.consumer,
270+
"method": server_error.method,
271+
"path": server_error.path,
272+
"type": server_error.type,
273+
"msg": server_error.msg,
274+
"traceback": server_error.traceback,
275+
"error_count": count,
276+
}
277+
)
278+
self.error_counts.clear()
279+
return data
280+
281+
@staticmethod
282+
def _get_truncated_exception_msg(exception: BaseException) -> str:
283+
msg = str(exception).strip()
284+
if len(msg) <= MAX_EXCEPTION_MSG_LENGTH:
285+
return msg
286+
suffix = "... (truncated)"
287+
cutoff = MAX_EXCEPTION_MSG_LENGTH - len(suffix)
288+
return msg[:cutoff] + suffix
289+
290+
@staticmethod
291+
def _get_truncated_exception_traceback(exception: BaseException) -> str:
292+
prefix = "... (truncated) ...\n"
293+
cutoff = MAX_EXCEPTION_TRACEBACK_LENGTH - len(prefix)
294+
lines = []
295+
length = 0
296+
for line in traceback.format_exception(exception)[::-1]:
297+
if length + len(line) > cutoff:
298+
lines.append(prefix)
299+
break
300+
lines.append(line)
301+
length += len(line)
302+
return "".join(lines[::-1]).strip()

apitally/django.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,22 @@ def __call__(self, request: HttpRequest) -> HttpResponse:
126126
)
127127
except Exception: # pragma: no cover
128128
logger.exception("Failed to log validation errors")
129+
if response.status_code == 500 and hasattr(request, "unhandled_exception"):
130+
try:
131+
self.client.server_error_counter.add_server_error(
132+
consumer=consumer,
133+
method=request.method,
134+
path=path,
135+
exception=getattr(request, "unhandled_exception"),
136+
)
137+
except Exception: # pragma: no cover
138+
logger.exception("Failed to log server error")
129139
return response
130140

141+
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
142+
setattr(request, "unhandled_exception", exception)
143+
return None
144+
131145
def get_path(self, request: HttpRequest) -> Optional[str]:
132146
if (match := request.resolver_match) is not None:
133147
try:

apitally/flask.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
66

77
from flask import Flask, g
8+
from flask.wrappers import Response
89
from werkzeug.datastructures import Headers
910
from werkzeug.exceptions import NotFound
1011
from werkzeug.test import Client
@@ -34,6 +35,7 @@ def __init__(
3435
self.app = app
3536
self.wsgi_app = app.wsgi_app
3637
self.filter_unhandled_paths = filter_unhandled_paths
38+
self.patch_handle_exception()
3739
self.client = ApitallyClient(client_id=client_id, env=env)
3840
self.client.start_sync_loop()
3941
self.delayed_set_app_info(app_version, openapi_url)
@@ -68,8 +70,21 @@ def catching_start_response(status: str, headers: List[Tuple[str, str]], exc_inf
6870
)
6971
return response
7072

73+
def patch_handle_exception(self) -> None:
74+
original_handle_exception = self.app.handle_exception
75+
76+
def handle_exception(e: Exception) -> Response:
77+
g.unhandled_exception = e
78+
return original_handle_exception(e)
79+
80+
self.app.handle_exception = handle_exception # type: ignore[method-assign]
81+
7182
def add_request(
72-
self, environ: WSGIEnvironment, status_code: int, response_time: float, response_headers: Headers
83+
self,
84+
environ: WSGIEnvironment,
85+
status_code: int,
86+
response_time: float,
87+
response_headers: Headers,
7388
) -> None:
7489
rule, is_handled_path = self.get_rule(environ)
7590
if is_handled_path or not self.filter_unhandled_paths:
@@ -82,6 +97,13 @@ def add_request(
8297
request_size=environ.get("CONTENT_LENGTH"),
8398
response_size=response_headers.get("Content-Length", type=int),
8499
)
100+
if status_code == 500 and "unhandled_exception" in g:
101+
self.client.server_error_counter.add_server_error(
102+
consumer=self.get_consumer(),
103+
method=environ["REQUEST_METHOD"],
104+
path=rule,
105+
exception=g.unhandled_exception,
106+
)
85107

86108
def get_rule(self, environ: WSGIEnvironment) -> Tuple[str, bool]:
87109
url_adapter = self.app.url_map.bind_to_environ(environ)

apitally/litestar.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def on_app_init(self, app_config: AppConfig) -> AppConfig:
3838
app_config.on_startup.append(self.on_startup)
3939
app_config.on_shutdown.append(self.client.handle_shutdown)
4040
app_config.middleware.append(self.middleware_factory)
41+
app_config.after_exception.append(self.after_exception)
4142
return app_config
4243

4344
def on_startup(self, app: Litestar) -> None:
@@ -58,6 +59,9 @@ def on_startup(self, app: Litestar) -> None:
5859
self.client.set_app_info(app_info)
5960
self.client.start_sync_loop()
6061

62+
def after_exception(self, exception: Exception, scope: Scope) -> None:
63+
scope["state"]["exception"] = exception
64+
6165
def middleware_factory(self, app: ASGIApp) -> ASGIApp:
6266
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
6367
if scope["type"] == "http" and scope["method"] != "OPTIONS":
@@ -139,6 +143,13 @@ def add_request(
139143
if "key" in error and "message" in error
140144
],
141145
)
146+
if response_status == 500 and "exception" in request.state:
147+
self.client.server_error_counter.add_server_error(
148+
consumer=consumer,
149+
method=request.method,
150+
path=path,
151+
exception=request.state["exception"],
152+
)
142153

143154
def get_path(self, request: Request) -> Optional[str]:
144155
path: List[str] = []

apitally/starlette.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
6464
response=None,
6565
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
6666
response_time=time.perf_counter() - start_time,
67+
exception=e,
6768
)
6869
raise e from None
6970
else:
@@ -76,7 +77,12 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
7677
return response
7778

7879
async def add_request(
79-
self, request: Request, response: Optional[Response], status_code: int, response_time: float
80+
self,
81+
request: Request,
82+
response: Optional[Response],
83+
status_code: int,
84+
response_time: float,
85+
exception: Optional[BaseException] = None,
8086
) -> None:
8187
path_template, is_handled_path = self.get_path_template(request)
8288
if is_handled_path or not self.filter_unhandled_paths:
@@ -104,6 +110,13 @@ async def add_request(
104110
path=path_template,
105111
detail=body["detail"],
106112
)
113+
if status_code == 500 and exception is not None:
114+
self.client.server_error_counter.add_server_error(
115+
consumer=consumer,
116+
method=request.method,
117+
path=path_template,
118+
exception=exception,
119+
)
107120

108121
@staticmethod
109122
async def get_response_json(response: Response) -> Any:

tests/test_client_base.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from pytest_mock import MockerFixture
2+
3+
14
def test_request_counter():
25
from apitally.client.base import RequestCounter
36

@@ -93,3 +96,48 @@ def test_validation_error_counter():
9396
assert data[0]["type"] == "type_error.integer"
9497
assert data[0]["msg"] == "value is not a valid integer"
9598
assert data[0]["error_count"] == 2
99+
100+
101+
def test_server_error_counter():
102+
from apitally.client.base import ServerErrorCounter
103+
104+
server_errors = ServerErrorCounter()
105+
server_errors.add_server_error(
106+
consumer=None,
107+
method="GET",
108+
path="/test",
109+
exception=ValueError("test"),
110+
)
111+
server_errors.add_server_error(
112+
consumer=None,
113+
method="GET",
114+
path="/test",
115+
exception=ValueError("test"),
116+
)
117+
118+
data = server_errors.get_and_reset_server_errors()
119+
assert len(server_errors.error_counts) == 0
120+
assert len(data) == 1
121+
assert data[0]["method"] == "GET"
122+
assert data[0]["path"] == "/test"
123+
assert data[0]["type"] == "builtins.ValueError"
124+
assert data[0]["msg"] == "test"
125+
assert data[0]["error_count"] == 2
126+
127+
128+
def test_exception_truncation(mocker: MockerFixture):
129+
from apitally.client.base import ServerErrorCounter
130+
131+
mocker.patch("apitally.client.base.MAX_EXCEPTION_MSG_LENGTH", 32)
132+
mocker.patch("apitally.client.base.MAX_EXCEPTION_TRACEBACK_LENGTH", 128)
133+
134+
try:
135+
raise ValueError("a" * 88)
136+
except ValueError as e:
137+
msg = ServerErrorCounter._get_truncated_exception_msg(e)
138+
tb = ServerErrorCounter._get_truncated_exception_traceback(e)
139+
140+
assert len(msg) == 32
141+
assert msg.endswith("... (truncated)")
142+
assert len(tb) <= 128
143+
assert tb.startswith("... (truncated) ...\n")

tests/test_django_ninja.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,22 @@ def test_middleware_requests_404(client: Client, mocker: MockerFixture):
9797

9898

9999
def test_middleware_requests_error(client: Client, mocker: MockerFixture):
100-
mock = mocker.patch("apitally.client.base.RequestCounter.add_request")
100+
mock1 = mocker.patch("apitally.client.base.RequestCounter.add_request")
101+
mock2 = mocker.patch("apitally.client.base.ServerErrorCounter.add_server_error")
101102

102103
response = client.put("/api/baz")
103104
assert response.status_code == 500
104-
mock.assert_called_once()
105-
assert mock.call_args is not None
106-
assert mock.call_args.kwargs["method"] == "PUT"
107-
assert mock.call_args.kwargs["path"] == "/api/baz"
108-
assert mock.call_args.kwargs["status_code"] == 500
109-
assert mock.call_args.kwargs["response_time"] > 0
105+
mock1.assert_called_once()
106+
assert mock1.call_args is not None
107+
assert mock1.call_args.kwargs["method"] == "PUT"
108+
assert mock1.call_args.kwargs["path"] == "/api/baz"
109+
assert mock1.call_args.kwargs["status_code"] == 500
110+
assert mock1.call_args.kwargs["response_time"] > 0
111+
112+
mock2.assert_called_once()
113+
assert mock2.call_args is not None
114+
exception = mock2.call_args.kwargs["exception"]
115+
assert isinstance(exception, ValueError)
110116

111117

112118
def test_middleware_validation_error(client: Client, mocker: MockerFixture):

tests/test_django_rest_framework.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,22 @@ def test_middleware_requests_404(client: APIClient, mocker: MockerFixture):
9595

9696

9797
def test_middleware_requests_error(client: APIClient, mocker: MockerFixture):
98-
mock = mocker.patch("apitally.client.base.RequestCounter.add_request")
98+
mock1 = mocker.patch("apitally.client.base.RequestCounter.add_request")
99+
mock2 = mocker.patch("apitally.client.base.ServerErrorCounter.add_server_error")
99100

100101
response = client.put("/baz/")
101102
assert response.status_code == 500
102-
mock.assert_called_once()
103-
assert mock.call_args is not None
104-
assert mock.call_args.kwargs["method"] == "PUT"
105-
assert mock.call_args.kwargs["path"] == "/baz/"
106-
assert mock.call_args.kwargs["status_code"] == 500
107-
assert mock.call_args.kwargs["response_time"] > 0
103+
mock1.assert_called_once()
104+
assert mock1.call_args is not None
105+
assert mock1.call_args.kwargs["method"] == "PUT"
106+
assert mock1.call_args.kwargs["path"] == "/baz/"
107+
assert mock1.call_args.kwargs["status_code"] == 500
108+
assert mock1.call_args.kwargs["response_time"] > 0
109+
110+
mock2.assert_called_once()
111+
assert mock2.call_args is not None
112+
exception = mock2.call_args.kwargs["exception"]
113+
assert isinstance(exception, ValueError)
108114

109115

110116
def test_get_app_info():

0 commit comments

Comments
 (0)