apitally 0.7.0__py3-none-any.whl → 0.8.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 +78 -0
- apitally/django.py +44 -5
- apitally/flask.py +23 -1
- apitally/litestar.py +18 -2
- apitally/starlette.py +14 -1
- {apitally-0.7.0.dist-info → apitally-0.8.0.dist-info}/METADATA +1 -1
- {apitally-0.7.0.dist-info → apitally-0.8.0.dist-info}/RECORD +10 -10
- {apitally-0.7.0.dist-info → apitally-0.8.0.dist-info}/LICENSE +0 -0
- {apitally-0.7.0.dist-info → apitally-0.8.0.dist-info}/WHEEL +0 -0
apitally/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "0.
|
1
|
+
__version__ = "0.8.0"
|
apitally/client/base.py
CHANGED
@@ -5,6 +5,7 @@ import os
|
|
5
5
|
import re
|
6
6
|
import threading
|
7
7
|
import time
|
8
|
+
import traceback
|
8
9
|
from abc import ABC
|
9
10
|
from collections import Counter
|
10
11
|
from dataclasses import dataclass
|
@@ -24,6 +25,8 @@ MAX_QUEUE_TIME = 3600
|
|
24
25
|
SYNC_INTERVAL = 60
|
25
26
|
INITIAL_SYNC_INTERVAL = 10
|
26
27
|
INITIAL_SYNC_INTERVAL_DURATION = 3600
|
28
|
+
MAX_EXCEPTION_MSG_LENGTH = 2048
|
29
|
+
MAX_EXCEPTION_TRACEBACK_LENGTH = 65536
|
27
30
|
|
28
31
|
TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase")
|
29
32
|
|
@@ -54,6 +57,7 @@ class ApitallyClientBase(ABC):
|
|
54
57
|
self.instance_uuid = str(uuid4())
|
55
58
|
self.request_counter = RequestCounter()
|
56
59
|
self.validation_error_counter = ValidationErrorCounter()
|
60
|
+
self.server_error_counter = ServerErrorCounter()
|
57
61
|
|
58
62
|
self._app_info_payload: Optional[Dict[str, Any]] = None
|
59
63
|
self._app_info_sent = False
|
@@ -86,11 +90,13 @@ class ApitallyClientBase(ABC):
|
|
86
90
|
def get_requests_payload(self) -> Dict[str, Any]:
|
87
91
|
requests = self.request_counter.get_and_reset_requests()
|
88
92
|
validation_errors = self.validation_error_counter.get_and_reset_validation_errors()
|
93
|
+
server_errors = self.server_error_counter.get_and_reset_server_errors()
|
89
94
|
return {
|
90
95
|
"instance_uuid": self.instance_uuid,
|
91
96
|
"message_uuid": str(uuid4()),
|
92
97
|
"requests": requests,
|
93
98
|
"validation_errors": validation_errors,
|
99
|
+
"server_errors": server_errors,
|
94
100
|
}
|
95
101
|
|
96
102
|
|
@@ -222,3 +228,75 @@ class ValidationErrorCounter:
|
|
222
228
|
)
|
223
229
|
self.error_counts.clear()
|
224
230
|
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
CHANGED
@@ -40,18 +40,25 @@ class ApitallyMiddleware:
|
|
40
40
|
|
41
41
|
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
42
42
|
self.get_response = get_response
|
43
|
-
self.
|
43
|
+
self.drf_available = _check_import("rest_framework")
|
44
44
|
self.drf_endpoint_enumerator = None
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
self.drf_endpoint_enumerator = EndpointEnumerator()
|
45
|
+
self.ninja_available = _check_import("ninja")
|
46
|
+
self.callbacks = set()
|
49
47
|
|
50
48
|
if self.config is None:
|
51
49
|
config = getattr(settings, "APITALLY_MIDDLEWARE", {})
|
52
50
|
self.configure(**config)
|
53
51
|
assert self.config is not None
|
54
52
|
|
53
|
+
if self.drf_available:
|
54
|
+
from rest_framework.schemas.generators import EndpointEnumerator
|
55
|
+
|
56
|
+
self.drf_endpoint_enumerator = EndpointEnumerator()
|
57
|
+
if None not in self.config.urlconfs:
|
58
|
+
self.callbacks.update(_get_drf_callbacks(self.config.urlconfs))
|
59
|
+
if self.ninja_available and None not in self.config.urlconfs:
|
60
|
+
self.callbacks.update(_get_ninja_callbacks(self.config.urlconfs))
|
61
|
+
|
55
62
|
self.client = ApitallyClient(client_id=self.config.client_id, env=self.config.env)
|
56
63
|
self.client.start_sync_loop()
|
57
64
|
self.client.set_app_info(
|
@@ -119,11 +126,27 @@ class ApitallyMiddleware:
|
|
119
126
|
)
|
120
127
|
except Exception: # pragma: no cover
|
121
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")
|
122
139
|
return response
|
123
140
|
|
141
|
+
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
|
142
|
+
setattr(request, "unhandled_exception", exception)
|
143
|
+
return None
|
144
|
+
|
124
145
|
def get_path(self, request: HttpRequest) -> Optional[str]:
|
125
146
|
if (match := request.resolver_match) is not None:
|
126
147
|
try:
|
148
|
+
if self.callbacks and match.func not in self.callbacks:
|
149
|
+
return None
|
127
150
|
if self.drf_endpoint_enumerator is not None:
|
128
151
|
from rest_framework.schemas.generators import is_api_view
|
129
152
|
|
@@ -206,6 +229,13 @@ def _get_drf_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
|
|
206
229
|
]
|
207
230
|
|
208
231
|
|
232
|
+
def _get_drf_callbacks(urlconfs: List[Optional[str]]) -> Set[Callable]:
|
233
|
+
from rest_framework.schemas.generators import EndpointEnumerator
|
234
|
+
|
235
|
+
enumerators = [EndpointEnumerator(urlconf=urlconf) for urlconf in urlconfs]
|
236
|
+
return {callback for enumerator in enumerators for _, _, callback in enumerator.get_api_endpoints()}
|
237
|
+
|
238
|
+
|
209
239
|
def _get_drf_schema(urlconfs: List[Optional[str]]) -> Optional[Dict[str, Any]]:
|
210
240
|
from rest_framework.schemas.openapi import SchemaGenerator
|
211
241
|
|
@@ -237,6 +267,15 @@ def _get_ninja_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
|
|
237
267
|
return endpoints
|
238
268
|
|
239
269
|
|
270
|
+
def _get_ninja_callbacks(urlconfs: List[Optional[str]]) -> Set[Callable]:
|
271
|
+
return {
|
272
|
+
path_view.get_view()
|
273
|
+
for api in _get_ninja_api_instances(urlconfs=urlconfs)
|
274
|
+
for _, router in api._routers
|
275
|
+
for path_view in router.path_operations.values()
|
276
|
+
}
|
277
|
+
|
278
|
+
|
240
279
|
def _get_ninja_schema(urlconfs: List[Optional[str]]) -> Optional[Dict[str, Any]]:
|
241
280
|
schemas = []
|
242
281
|
for api in _get_ninja_api_instances(urlconfs=urlconfs):
|
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,19 +1,19 @@
|
|
1
|
-
apitally/__init__.py,sha256=
|
1
|
+
apitally/__init__.py,sha256=iPlYCcIzuzW7T2HKDkmYlMkRI51dBLfNRxPPiWrfw9U,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=-2Bw_zFq6de-mSQSYsTa0NSRoMcHwiZqpSCQnjjIZ2M,11351
|
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.8.0.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
17
|
+
apitally-0.8.0.dist-info/METADATA,sha256=jl6_hdLWMu-CStH70iJ-4-Lt94328tX_X33avx-PIqI,6736
|
18
|
+
apitally-0.8.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
19
|
+
apitally-0.8.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|