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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.7.1"
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, 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.1
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=2KJZDSMOG7KS82AxYOrZ4ZihYxX0wjfUjDsIZh3L024,22
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=E_yUTItAtZWPOx80K3Pm55CNBgF_NZ6RMyXZTnjSV9c,8511
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=SHSM00eRsvoJC2d4c4tK6gctKLnxLfrw4LoZ4CWPm4U,12124
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.1.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
17
- apitally-0.7.1.dist-info/METADATA,sha256=SO16kB-1EwZGAm28P4m4E2EMltFfmKUy4uF44X4LB30,6736
18
- apitally-0.7.1.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
19
- apitally-0.7.1.dist-info/RECORD,,
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,,