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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.7.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.ninja_available = _check_import("ninja")
43
+ self.drf_available = _check_import("rest_framework")
44
44
  self.drf_endpoint_enumerator = None
45
- if _check_import("rest_framework"):
46
- from rest_framework.schemas.generators import EndpointEnumerator
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, 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,
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: Optional[str] = None
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
- self.openapi_path = openapi_config.openapi_controller.path
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, 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,
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.7.0
3
+ Version: 0.8.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
@@ -1,19 +1,19 @@
1
- apitally/__init__.py,sha256=RaANGbRu5e-vehwXI1-Qe2ggPPfs1TQaZj072JdbLk4,22
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=E_yUTItAtZWPOx80K3Pm55CNBgF_NZ6RMyXZTnjSV9c,8511
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=tp1bsBGyfG41yKMBHrqNG2rOyO4ToB7Fal40TTN4qvo,11052
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=xxyrHchMiPuEMP3c_Us_niJn9K7x87RDvxD5GtntEvU,4769
13
- apitally/litestar.py,sha256=Pl1tEbxve3vqrdflWdvZjPFrEVxNIr6NjuQVf4YSpzY,7171
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=9VKGdNuKPrRcnQv7GeyV5cLa7TpgidxMVnAKSxMsWjI,7345
16
- apitally-0.7.0.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
17
- apitally-0.7.0.dist-info/METADATA,sha256=huzvHmuBeVxea6HKDwXnT6ei8zrBI7RD7pdweMdKkEQ,6736
18
- apitally-0.7.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
19
- apitally-0.7.0.dist-info/RECORD,,
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,,