apitally 0.7.1__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apitally/__init__.py +1 -1
- apitally/client/base.py +112 -0
- apitally/django.py +14 -0
- apitally/flask.py +23 -1
- apitally/litestar.py +18 -2
- apitally/starlette.py +14 -1
- {apitally-0.7.1.dist-info → apitally-0.9.0.dist-info}/METADATA +3 -1
- {apitally-0.7.1.dist-info → apitally-0.9.0.dist-info}/RECORD +10 -10
- {apitally-0.7.1.dist-info → apitally-0.9.0.dist-info}/LICENSE +0 -0
- {apitally-0.7.1.dist-info → apitally-0.9.0.dist-info}/WHEEL +0 -0
apitally/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "0.
|
1
|
+
__version__ = "0.9.0"
|
apitally/client/base.py
CHANGED
@@ -1,10 +1,12 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import asyncio
|
3
4
|
import contextlib
|
4
5
|
import os
|
5
6
|
import re
|
6
7
|
import threading
|
7
8
|
import time
|
9
|
+
import traceback
|
8
10
|
from abc import ABC
|
9
11
|
from collections import Counter
|
10
12
|
from dataclasses import dataclass
|
@@ -24,6 +26,8 @@ MAX_QUEUE_TIME = 3600
|
|
24
26
|
SYNC_INTERVAL = 60
|
25
27
|
INITIAL_SYNC_INTERVAL = 10
|
26
28
|
INITIAL_SYNC_INTERVAL_DURATION = 3600
|
29
|
+
MAX_EXCEPTION_MSG_LENGTH = 2048
|
30
|
+
MAX_EXCEPTION_TRACEBACK_LENGTH = 65536
|
27
31
|
|
28
32
|
TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase")
|
29
33
|
|
@@ -54,6 +58,7 @@ class ApitallyClientBase(ABC):
|
|
54
58
|
self.instance_uuid = str(uuid4())
|
55
59
|
self.request_counter = RequestCounter()
|
56
60
|
self.validation_error_counter = ValidationErrorCounter()
|
61
|
+
self.server_error_counter = ServerErrorCounter()
|
57
62
|
|
58
63
|
self._app_info_payload: Optional[Dict[str, Any]] = None
|
59
64
|
self._app_info_sent = False
|
@@ -86,11 +91,13 @@ class ApitallyClientBase(ABC):
|
|
86
91
|
def get_requests_payload(self) -> Dict[str, Any]:
|
87
92
|
requests = self.request_counter.get_and_reset_requests()
|
88
93
|
validation_errors = self.validation_error_counter.get_and_reset_validation_errors()
|
94
|
+
server_errors = self.server_error_counter.get_and_reset_server_errors()
|
89
95
|
return {
|
90
96
|
"instance_uuid": self.instance_uuid,
|
91
97
|
"message_uuid": str(uuid4()),
|
92
98
|
"requests": requests,
|
93
99
|
"validation_errors": validation_errors,
|
100
|
+
"server_errors": server_errors,
|
94
101
|
}
|
95
102
|
|
96
103
|
|
@@ -222,3 +229,108 @@ class ValidationErrorCounter:
|
|
222
229
|
)
|
223
230
|
self.error_counts.clear()
|
224
231
|
return data
|
232
|
+
|
233
|
+
|
234
|
+
@dataclass(frozen=True)
|
235
|
+
class ServerError:
|
236
|
+
consumer: Optional[str]
|
237
|
+
method: str
|
238
|
+
path: str
|
239
|
+
type: str
|
240
|
+
msg: str
|
241
|
+
traceback: str
|
242
|
+
|
243
|
+
|
244
|
+
class ServerErrorCounter:
|
245
|
+
def __init__(self) -> None:
|
246
|
+
self.error_counts: Counter[ServerError] = Counter()
|
247
|
+
self.sentry_event_ids: Dict[ServerError, str] = {}
|
248
|
+
self._lock = threading.Lock()
|
249
|
+
|
250
|
+
def add_server_error(self, consumer: Optional[str], method: str, path: str, exception: BaseException) -> None:
|
251
|
+
if not isinstance(exception, BaseException):
|
252
|
+
return # pragma: no cover
|
253
|
+
exception_type = type(exception)
|
254
|
+
with self._lock:
|
255
|
+
server_error = ServerError(
|
256
|
+
consumer=consumer,
|
257
|
+
method=method.upper(),
|
258
|
+
path=path,
|
259
|
+
type=f"{exception_type.__module__}.{exception_type.__qualname__}",
|
260
|
+
msg=self._get_truncated_exception_msg(exception),
|
261
|
+
traceback=self._get_truncated_exception_traceback(exception),
|
262
|
+
)
|
263
|
+
self.error_counts[server_error] += 1
|
264
|
+
self.capture_sentry_event_id(server_error)
|
265
|
+
|
266
|
+
def capture_sentry_event_id(self, server_error: ServerError) -> None:
|
267
|
+
try:
|
268
|
+
from sentry_sdk.hub import Hub
|
269
|
+
from sentry_sdk.scope import Scope
|
270
|
+
except ImportError:
|
271
|
+
return # pragma: no cover
|
272
|
+
if not hasattr(Scope, "get_isolation_scope") or not hasattr(Scope, "last_event_id"):
|
273
|
+
# sentry-sdk < 2.2.0 is not supported
|
274
|
+
return # pragma: no cover
|
275
|
+
if Hub.current.client is None:
|
276
|
+
return # sentry-sdk not initialized
|
277
|
+
|
278
|
+
scope = Scope.get_isolation_scope()
|
279
|
+
if event_id := scope.last_event_id():
|
280
|
+
self.sentry_event_ids[server_error] = event_id
|
281
|
+
return
|
282
|
+
|
283
|
+
async def _wait_for_sentry_event_id(scope: Scope) -> None:
|
284
|
+
i = 0
|
285
|
+
while not (event_id := scope.last_event_id()) and i < 100:
|
286
|
+
i += 1
|
287
|
+
await asyncio.sleep(0.001)
|
288
|
+
if event_id:
|
289
|
+
self.sentry_event_ids[server_error] = event_id
|
290
|
+
|
291
|
+
with contextlib.suppress(RuntimeError): # ignore no running loop
|
292
|
+
loop = asyncio.get_running_loop()
|
293
|
+
loop.create_task(_wait_for_sentry_event_id(scope))
|
294
|
+
|
295
|
+
def get_and_reset_server_errors(self) -> List[Dict[str, Any]]:
|
296
|
+
data: List[Dict[str, Any]] = []
|
297
|
+
with self._lock:
|
298
|
+
for server_error, count in self.error_counts.items():
|
299
|
+
data.append(
|
300
|
+
{
|
301
|
+
"consumer": server_error.consumer,
|
302
|
+
"method": server_error.method,
|
303
|
+
"path": server_error.path,
|
304
|
+
"type": server_error.type,
|
305
|
+
"msg": server_error.msg,
|
306
|
+
"traceback": server_error.traceback,
|
307
|
+
"sentry_event_id": self.sentry_event_ids.get(server_error),
|
308
|
+
"error_count": count,
|
309
|
+
}
|
310
|
+
)
|
311
|
+
self.error_counts.clear()
|
312
|
+
self.sentry_event_ids.clear()
|
313
|
+
return data
|
314
|
+
|
315
|
+
@staticmethod
|
316
|
+
def _get_truncated_exception_msg(exception: BaseException) -> str:
|
317
|
+
msg = str(exception).strip()
|
318
|
+
if len(msg) <= MAX_EXCEPTION_MSG_LENGTH:
|
319
|
+
return msg
|
320
|
+
suffix = "... (truncated)"
|
321
|
+
cutoff = MAX_EXCEPTION_MSG_LENGTH - len(suffix)
|
322
|
+
return msg[:cutoff] + suffix
|
323
|
+
|
324
|
+
@staticmethod
|
325
|
+
def _get_truncated_exception_traceback(exception: BaseException) -> str:
|
326
|
+
prefix = "... (truncated) ...\n"
|
327
|
+
cutoff = MAX_EXCEPTION_TRACEBACK_LENGTH - len(prefix)
|
328
|
+
lines = []
|
329
|
+
length = 0
|
330
|
+
for line in traceback.format_exception(exception)[::-1]:
|
331
|
+
if length + len(line) > cutoff:
|
332
|
+
lines.append(prefix)
|
333
|
+
break
|
334
|
+
lines.append(line)
|
335
|
+
length += len(line)
|
336
|
+
return "".join(lines[::-1]).strip()
|
apitally/django.py
CHANGED
@@ -126,8 +126,22 @@ class ApitallyMiddleware:
|
|
126
126
|
)
|
127
127
|
except Exception: # pragma: no cover
|
128
128
|
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")
|
129
139
|
return response
|
130
140
|
|
141
|
+
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
|
142
|
+
setattr(request, "unhandled_exception", exception)
|
143
|
+
return None
|
144
|
+
|
131
145
|
def get_path(self, request: HttpRequest) -> Optional[str]:
|
132
146
|
if (match := request.resolver_match) is not None:
|
133
147
|
try:
|
apitally/flask.py
CHANGED
@@ -5,6 +5,7 @@ from threading import Timer
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
|
6
6
|
|
7
7
|
from flask import Flask, g
|
8
|
+
from flask.wrappers import Response
|
8
9
|
from werkzeug.datastructures import Headers
|
9
10
|
from werkzeug.exceptions import NotFound
|
10
11
|
from werkzeug.test import Client
|
@@ -34,6 +35,7 @@ class ApitallyMiddleware:
|
|
34
35
|
self.app = app
|
35
36
|
self.wsgi_app = app.wsgi_app
|
36
37
|
self.filter_unhandled_paths = filter_unhandled_paths
|
38
|
+
self.patch_handle_exception()
|
37
39
|
self.client = ApitallyClient(client_id=client_id, env=env)
|
38
40
|
self.client.start_sync_loop()
|
39
41
|
self.delayed_set_app_info(app_version, openapi_url)
|
@@ -68,8 +70,21 @@ class ApitallyMiddleware:
|
|
68
70
|
)
|
69
71
|
return response
|
70
72
|
|
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
|
+
|
71
82
|
def add_request(
|
72
|
-
self,
|
83
|
+
self,
|
84
|
+
environ: WSGIEnvironment,
|
85
|
+
status_code: int,
|
86
|
+
response_time: float,
|
87
|
+
response_headers: Headers,
|
73
88
|
) -> None:
|
74
89
|
rule, is_handled_path = self.get_rule(environ)
|
75
90
|
if is_handled_path or not self.filter_unhandled_paths:
|
@@ -82,6 +97,13 @@ class ApitallyMiddleware:
|
|
82
97
|
request_size=environ.get("CONTENT_LENGTH"),
|
83
98
|
response_size=response_headers.get("Content-Length", type=int),
|
84
99
|
)
|
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
|
+
)
|
85
107
|
|
86
108
|
def get_rule(self, environ: WSGIEnvironment) -> Tuple[str, bool]:
|
87
109
|
url_adapter = self.app.url_map.bind_to_environ(environ)
|
apitally/litestar.py
CHANGED
@@ -32,17 +32,23 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
32
32
|
self.app_version = app_version
|
33
33
|
self.filter_openapi_paths = filter_openapi_paths
|
34
34
|
self.identify_consumer_callback = identify_consumer_callback
|
35
|
-
self.openapi_path
|
35
|
+
self.openapi_path = "/schema"
|
36
36
|
|
37
37
|
def on_app_init(self, app_config: AppConfig) -> AppConfig:
|
38
38
|
app_config.on_startup.append(self.on_startup)
|
39
39
|
app_config.on_shutdown.append(self.client.handle_shutdown)
|
40
40
|
app_config.middleware.append(self.middleware_factory)
|
41
|
+
app_config.after_exception.append(self.after_exception)
|
41
42
|
return app_config
|
42
43
|
|
43
44
|
def on_startup(self, app: Litestar) -> None:
|
44
45
|
openapi_config = app.openapi_config or DEFAULT_OPENAPI_CONFIG
|
45
|
-
|
46
|
+
if openapi_config.openapi_controller is not None:
|
47
|
+
self.openapi_path = openapi_config.openapi_controller.path
|
48
|
+
elif hasattr(openapi_config, "openapi_router") and openapi_config.openapi_router is not None:
|
49
|
+
self.openapi_path = openapi_config.openapi_router.path
|
50
|
+
elif openapi_config.path is not None:
|
51
|
+
self.openapi_path = openapi_config.path
|
46
52
|
|
47
53
|
app_info = {
|
48
54
|
"openapi": _get_openapi(app),
|
@@ -53,6 +59,9 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
53
59
|
self.client.set_app_info(app_info)
|
54
60
|
self.client.start_sync_loop()
|
55
61
|
|
62
|
+
def after_exception(self, exception: Exception, scope: Scope) -> None:
|
63
|
+
scope["state"]["exception"] = exception
|
64
|
+
|
56
65
|
def middleware_factory(self, app: ASGIApp) -> ASGIApp:
|
57
66
|
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
|
58
67
|
if scope["type"] == "http" and scope["method"] != "OPTIONS":
|
@@ -134,6 +143,13 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
134
143
|
if "key" in error and "message" in error
|
135
144
|
],
|
136
145
|
)
|
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
|
+
)
|
137
153
|
|
138
154
|
def get_path(self, request: Request) -> Optional[str]:
|
139
155
|
path: List[str] = []
|
apitally/starlette.py
CHANGED
@@ -64,6 +64,7 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
64
64
|
response=None,
|
65
65
|
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
|
66
66
|
response_time=time.perf_counter() - start_time,
|
67
|
+
exception=e,
|
67
68
|
)
|
68
69
|
raise e from None
|
69
70
|
else:
|
@@ -76,7 +77,12 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
76
77
|
return response
|
77
78
|
|
78
79
|
async def add_request(
|
79
|
-
self,
|
80
|
+
self,
|
81
|
+
request: Request,
|
82
|
+
response: Optional[Response],
|
83
|
+
status_code: int,
|
84
|
+
response_time: float,
|
85
|
+
exception: Optional[BaseException] = None,
|
80
86
|
) -> None:
|
81
87
|
path_template, is_handled_path = self.get_path_template(request)
|
82
88
|
if is_handled_path or not self.filter_unhandled_paths:
|
@@ -104,6 +110,13 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
104
110
|
path=path_template,
|
105
111
|
detail=body["detail"],
|
106
112
|
)
|
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
|
+
)
|
107
120
|
|
108
121
|
@staticmethod
|
109
122
|
async def get_response_json(response: Response) -> Any:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: apitally
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.9.0
|
4
4
|
Summary: API monitoring for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
|
5
5
|
Home-page: https://apitally.io
|
6
6
|
License: MIT
|
@@ -26,6 +26,7 @@ Provides-Extra: django-rest-framework
|
|
26
26
|
Provides-Extra: fastapi
|
27
27
|
Provides-Extra: flask
|
28
28
|
Provides-Extra: litestar
|
29
|
+
Provides-Extra: sentry
|
29
30
|
Provides-Extra: starlette
|
30
31
|
Requires-Dist: backoff (>=2.0.0)
|
31
32
|
Requires-Dist: django (>=2.2) ; extra == "django-ninja" or extra == "django-rest-framework"
|
@@ -37,6 +38,7 @@ Requires-Dist: httpx (>=0.22.0) ; extra == "fastapi" or extra == "litestar" or e
|
|
37
38
|
Requires-Dist: inflection (>=0.5.1) ; extra == "django-rest-framework"
|
38
39
|
Requires-Dist: litestar (>=2.0.0) ; extra == "litestar"
|
39
40
|
Requires-Dist: requests (>=2.26.0) ; extra == "django-ninja" or extra == "django-rest-framework" or extra == "flask"
|
41
|
+
Requires-Dist: sentry-sdk (>=2.2.0) ; extra == "sentry"
|
40
42
|
Requires-Dist: starlette (>=0.21.0,<1.0.0) ; extra == "fastapi" or extra == "starlette"
|
41
43
|
Requires-Dist: uritemplate (>=3.0.0) ; extra == "django-rest-framework"
|
42
44
|
Project-URL: Documentation, https://docs.apitally.io
|
@@ -1,19 +1,19 @@
|
|
1
|
-
apitally/__init__.py,sha256=
|
1
|
+
apitally/__init__.py,sha256=H9NWRZb7NbeRRPLP_V1fARmLNXranorVM-OOY-8_2ug,22
|
2
2
|
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
3
|
apitally/client/asyncio.py,sha256=uR5JlH37G6gZvAJ7A1gYOGkjn3zjC-4I6avA1fncXHs,4433
|
4
|
-
apitally/client/base.py,sha256=
|
4
|
+
apitally/client/base.py,sha256=VWtHTkA6UT5aV37i_CXUvcfmYrabjHGBhzbDU6EeH1A,12785
|
5
5
|
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
6
6
|
apitally/client/threading.py,sha256=ihQzUStrSQFynpqXgFpseAXrHuc5Et1QvG-YHlzqDr8,4831
|
7
7
|
apitally/common.py,sha256=GbVmnXxhRvV30d7CfCQ9r0AeXj14Mv9Jm_Yd1bRWP28,1088
|
8
|
-
apitally/django.py,sha256=
|
8
|
+
apitally/django.py,sha256=Ym590Tiz1Qnk-IgFcna8r-XkSgcln2riziSPZCwD9nw,12812
|
9
9
|
apitally/django_ninja.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
|
10
10
|
apitally/django_rest_framework.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
|
11
11
|
apitally/fastapi.py,sha256=Q3n2bVREKQ_V_2yCQ48ngPtr-NJxDskpT_l20xhSbpM,85
|
12
|
-
apitally/flask.py,sha256=
|
13
|
-
apitally/litestar.py,sha256=
|
12
|
+
apitally/flask.py,sha256=Utn92aXXl_1f4bKvuf4iZDB4v1vVLpeW5p1tF57Kf-8,5552
|
13
|
+
apitally/litestar.py,sha256=1-skfFDKjYa7y6mOdNvjR4YGtsQbNA0iGQP1jyre41Q,7978
|
14
14
|
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
15
|
-
apitally/starlette.py,sha256=
|
16
|
-
apitally-0.
|
17
|
-
apitally-0.
|
18
|
-
apitally-0.
|
19
|
-
apitally-0.
|
15
|
+
apitally/starlette.py,sha256=kyXgzw0L90GUOZCgjuwM3q0uOpPX-hD0TQb2Wgqbqb8,7767
|
16
|
+
apitally-0.9.0.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
17
|
+
apitally-0.9.0.dist-info/METADATA,sha256=SsnJeg47jd8Q8MP9RXC9mbmweeE8ywLCxOLtQks3VKQ,6815
|
18
|
+
apitally-0.9.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
19
|
+
apitally-0.9.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|