apitally 0.14.5__py3-none-any.whl → 0.15.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/client/client_asyncio.py +2 -0
- apitally/client/client_base.py +1 -0
- apitally/client/client_threading.py +1 -0
- apitally/client/request_logging.py +32 -11
- apitally/client/sentry.py +38 -0
- apitally/client/server_errors.py +37 -63
- apitally/django.py +9 -2
- apitally/flask.py +7 -1
- apitally/litestar.py +11 -2
- apitally/starlette.py +11 -2
- {apitally-0.14.5.dist-info → apitally-0.15.0.dist-info}/METADATA +71 -34
- apitally-0.15.0.dist-info/RECORD +25 -0
- apitally-0.14.5.dist-info/RECORD +0 -24
- {apitally-0.14.5.dist-info → apitally-0.15.0.dist-info}/WHEEL +0 -0
- {apitally-0.14.5.dist-info → apitally-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -81,6 +81,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
81
81
|
self._stop_sync_loop = True
|
82
82
|
|
83
83
|
async def handle_shutdown(self) -> None:
|
84
|
+
self.enabled = False
|
84
85
|
if self._sync_loop_task is not None:
|
85
86
|
self._sync_loop_task.cancel()
|
86
87
|
# Send any remaining data before exiting
|
@@ -164,6 +165,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
164
165
|
|
165
166
|
def _handle_hub_response(self, response: httpx.Response) -> None:
|
166
167
|
if response.status_code == 404:
|
168
|
+
self.enabled = False
|
167
169
|
self.stop_sync_loop()
|
168
170
|
logger.error("Invalid Apitally client ID: %s", self.client_id)
|
169
171
|
elif response.status_code == 422:
|
apitally/client/client_base.py
CHANGED
@@ -190,6 +190,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
190
190
|
|
191
191
|
def _handle_hub_response(self, response: requests.Response) -> None:
|
192
192
|
if response.status_code == 404:
|
193
|
+
self.enabled = False
|
193
194
|
self.stop_sync_loop()
|
194
195
|
logger.error("Invalid Apitally client ID: %s", self.client_id)
|
195
196
|
elif response.status_code == 422:
|
@@ -14,6 +14,12 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
14
14
|
from uuid import uuid4
|
15
15
|
|
16
16
|
from apitally.client.logging import get_logger
|
17
|
+
from apitally.client.sentry import get_sentry_event_id_async
|
18
|
+
from apitally.client.server_errors import (
|
19
|
+
get_exception_type,
|
20
|
+
get_truncated_exception_msg,
|
21
|
+
get_truncated_exception_traceback,
|
22
|
+
)
|
17
23
|
|
18
24
|
|
19
25
|
logger = get_logger(__name__)
|
@@ -88,6 +94,7 @@ class RequestLoggingConfig:
|
|
88
94
|
log_request_body: Whether to log the request body (only if JSON or plain text)
|
89
95
|
log_response_headers: Whether to log response header values
|
90
96
|
log_response_body: Whether to log the response body (only if JSON or plain text)
|
97
|
+
log_exception: Whether to log unhandled exceptions in case of server errors
|
91
98
|
mask_query_params: Query parameter names to mask in logs. Expects regular expressions.
|
92
99
|
mask_headers: Header names to mask in logs. Expects regular expressions.
|
93
100
|
mask_request_body_callback: Callback to mask the request body. Expects (method, path, body) and returns the masked body as bytes or None.
|
@@ -102,6 +109,7 @@ class RequestLoggingConfig:
|
|
102
109
|
log_request_body: bool = False
|
103
110
|
log_response_headers: bool = True
|
104
111
|
log_response_body: bool = False
|
112
|
+
log_exception: bool = True
|
105
113
|
mask_query_params: List[str] = field(default_factory=list)
|
106
114
|
mask_headers: List[str] = field(default_factory=list)
|
107
115
|
mask_request_body_callback: Optional[Callable[[RequestDict], Optional[bytes]]] = None
|
@@ -153,7 +161,7 @@ class RequestLogger:
|
|
153
161
|
self.config = config or RequestLoggingConfig()
|
154
162
|
self.enabled = self.config.enabled and _check_writable_fs()
|
155
163
|
self.serialize = _get_json_serializer()
|
156
|
-
self.write_deque: deque[
|
164
|
+
self.write_deque: deque[Dict[str, Any]] = deque([], MAX_REQUESTS_IN_DEQUE)
|
157
165
|
self.file_deque: deque[TempGzipFile] = deque([])
|
158
166
|
self.file: Optional[TempGzipFile] = None
|
159
167
|
self.lock = threading.Lock()
|
@@ -163,7 +171,9 @@ class RequestLogger:
|
|
163
171
|
def current_file_size(self) -> int:
|
164
172
|
return self.file.size if self.file is not None else 0
|
165
173
|
|
166
|
-
def log_request(
|
174
|
+
def log_request(
|
175
|
+
self, request: RequestDict, response: ResponseDict, exception: Optional[BaseException] = None
|
176
|
+
) -> None:
|
167
177
|
if not self.enabled or self.suspend_until is not None:
|
168
178
|
return
|
169
179
|
parsed_url = urlparse(request["url"])
|
@@ -177,8 +187,6 @@ class RequestLogger:
|
|
177
187
|
|
178
188
|
query = self._mask_query_params(parsed_url.query) if self.config.log_query_params else ""
|
179
189
|
request["url"] = urlunparse(parsed_url._replace(query=query))
|
180
|
-
request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
|
181
|
-
response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
|
182
190
|
|
183
191
|
if not self.config.log_request_body or not self._has_supported_content_type(request["headers"]):
|
184
192
|
request["body"] = None
|
@@ -214,13 +222,22 @@ class RequestLogger:
|
|
214
222
|
if response["body"] is not None and len(response["body"]) > MAX_BODY_SIZE:
|
215
223
|
response["body"] = BODY_TOO_LARGE
|
216
224
|
|
217
|
-
|
225
|
+
request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
|
226
|
+
response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
|
227
|
+
|
228
|
+
item: Dict[str, Any] = {
|
218
229
|
"uuid": str(uuid4()),
|
219
230
|
"request": _skip_empty_values(request),
|
220
231
|
"response": _skip_empty_values(response),
|
221
232
|
}
|
222
|
-
|
223
|
-
|
233
|
+
if exception is not None and self.config.log_exception:
|
234
|
+
item["exception"] = {
|
235
|
+
"type": get_exception_type(exception),
|
236
|
+
"message": get_truncated_exception_msg(exception),
|
237
|
+
"traceback": get_truncated_exception_traceback(exception),
|
238
|
+
}
|
239
|
+
get_sentry_event_id_async(lambda event_id: item["exception"].update({"sentry_event_id": event_id}))
|
240
|
+
self.write_deque.append(item)
|
224
241
|
|
225
242
|
def write_to_file(self) -> None:
|
226
243
|
if not self.enabled or len(self.write_deque) == 0:
|
@@ -231,7 +248,7 @@ class RequestLogger:
|
|
231
248
|
while True:
|
232
249
|
try:
|
233
250
|
item = self.write_deque.popleft()
|
234
|
-
self.file.write_line(item)
|
251
|
+
self.file.write_line(self.serialize(item))
|
235
252
|
except IndexError:
|
236
253
|
break
|
237
254
|
|
@@ -310,14 +327,18 @@ class RequestLogger:
|
|
310
327
|
return True
|
311
328
|
return False
|
312
329
|
|
330
|
+
@staticmethod
|
331
|
+
def _get_user_agent(headers: List[Tuple[str, str]]) -> Optional[str]:
|
332
|
+
return next((v for k, v in headers if k.lower() == "user-agent"), None)
|
333
|
+
|
313
334
|
@staticmethod
|
314
335
|
def _has_supported_content_type(headers: List[Tuple[str, str]]) -> bool:
|
315
336
|
content_type = next((v for k, v in headers if k.lower() == "content-type"), None)
|
316
|
-
return
|
337
|
+
return RequestLogger.is_supported_content_type(content_type)
|
317
338
|
|
318
339
|
@staticmethod
|
319
|
-
def
|
320
|
-
return
|
340
|
+
def is_supported_content_type(content_type: Optional[str]) -> bool:
|
341
|
+
return content_type is not None and any(content_type.startswith(t) for t in ALLOWED_CONTENT_TYPES)
|
321
342
|
|
322
343
|
|
323
344
|
def _check_writable_fs() -> bool:
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import asyncio
|
2
|
+
import contextlib
|
3
|
+
from typing import Callable, Set
|
4
|
+
|
5
|
+
|
6
|
+
_tasks: Set[asyncio.Task] = set()
|
7
|
+
|
8
|
+
|
9
|
+
def get_sentry_event_id_async(cb: Callable[[str], None]) -> None:
|
10
|
+
try:
|
11
|
+
from sentry_sdk.hub import Hub
|
12
|
+
from sentry_sdk.scope import Scope
|
13
|
+
except ImportError:
|
14
|
+
return # pragma: no cover
|
15
|
+
if not hasattr(Scope, "get_isolation_scope") or not hasattr(Scope, "_last_event_id"):
|
16
|
+
# sentry-sdk < 2.2.0 is not supported
|
17
|
+
return # pragma: no cover
|
18
|
+
if Hub.current.client is None:
|
19
|
+
return # sentry-sdk not initialized
|
20
|
+
|
21
|
+
scope = Scope.get_isolation_scope()
|
22
|
+
if event_id := scope._last_event_id:
|
23
|
+
cb(event_id)
|
24
|
+
return
|
25
|
+
|
26
|
+
async def _wait_for_sentry_event_id(scope: Scope) -> None:
|
27
|
+
i = 0
|
28
|
+
while not (event_id := scope._last_event_id) and i < 100:
|
29
|
+
i += 1
|
30
|
+
await asyncio.sleep(0.001)
|
31
|
+
if event_id:
|
32
|
+
cb(event_id)
|
33
|
+
|
34
|
+
with contextlib.suppress(RuntimeError): # ignore no running loop
|
35
|
+
loop = asyncio.get_running_loop()
|
36
|
+
task = loop.create_task(_wait_for_sentry_event_id(scope))
|
37
|
+
_tasks.add(task)
|
38
|
+
task.add_done_callback(_tasks.discard)
|
apitally/client/server_errors.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import asyncio
|
4
|
-
import contextlib
|
5
4
|
import sys
|
6
5
|
import threading
|
7
6
|
import traceback
|
@@ -9,6 +8,8 @@ from collections import Counter
|
|
9
8
|
from dataclasses import dataclass
|
10
9
|
from typing import Any, Dict, List, Optional, Set
|
11
10
|
|
11
|
+
from apitally.client.sentry import get_sentry_event_id_async
|
12
|
+
|
12
13
|
|
13
14
|
MAX_EXCEPTION_MSG_LENGTH = 2048
|
14
15
|
MAX_EXCEPTION_TRACEBACK_LENGTH = 65536
|
@@ -34,49 +35,17 @@ class ServerErrorCounter:
|
|
34
35
|
def add_server_error(self, consumer: Optional[str], method: str, path: str, exception: BaseException) -> None:
|
35
36
|
if not isinstance(exception, BaseException):
|
36
37
|
return # pragma: no cover
|
37
|
-
exception_type = type(exception)
|
38
38
|
with self._lock:
|
39
39
|
server_error = ServerError(
|
40
40
|
consumer=consumer,
|
41
41
|
method=method.upper(),
|
42
42
|
path=path,
|
43
|
-
type=
|
44
|
-
msg=
|
45
|
-
traceback=
|
43
|
+
type=get_exception_type(exception),
|
44
|
+
msg=get_truncated_exception_msg(exception),
|
45
|
+
traceback=get_truncated_exception_traceback(exception),
|
46
46
|
)
|
47
47
|
self.error_counts[server_error] += 1
|
48
|
-
|
49
|
-
|
50
|
-
def capture_sentry_event_id(self, server_error: ServerError) -> None:
|
51
|
-
try:
|
52
|
-
from sentry_sdk.hub import Hub
|
53
|
-
from sentry_sdk.scope import Scope
|
54
|
-
except ImportError:
|
55
|
-
return # pragma: no cover
|
56
|
-
if not hasattr(Scope, "get_isolation_scope") or not hasattr(Scope, "_last_event_id"):
|
57
|
-
# sentry-sdk < 2.2.0 is not supported
|
58
|
-
return # pragma: no cover
|
59
|
-
if Hub.current.client is None:
|
60
|
-
return # sentry-sdk not initialized
|
61
|
-
|
62
|
-
scope = Scope.get_isolation_scope()
|
63
|
-
if event_id := scope._last_event_id:
|
64
|
-
self.sentry_event_ids[server_error] = event_id
|
65
|
-
return
|
66
|
-
|
67
|
-
async def _wait_for_sentry_event_id(scope: Scope) -> None:
|
68
|
-
i = 0
|
69
|
-
while not (event_id := scope._last_event_id) and i < 100:
|
70
|
-
i += 1
|
71
|
-
await asyncio.sleep(0.001)
|
72
|
-
if event_id:
|
73
|
-
self.sentry_event_ids[server_error] = event_id
|
74
|
-
|
75
|
-
with contextlib.suppress(RuntimeError): # ignore no running loop
|
76
|
-
loop = asyncio.get_running_loop()
|
77
|
-
task = loop.create_task(_wait_for_sentry_event_id(scope))
|
78
|
-
self._tasks.add(task)
|
79
|
-
task.add_done_callback(self._tasks.discard)
|
48
|
+
get_sentry_event_id_async(lambda event_id: self.sentry_event_ids.update({server_error: event_id}))
|
80
49
|
|
81
50
|
def get_and_reset_server_errors(self) -> List[Dict[str, Any]]:
|
82
51
|
data: List[Dict[str, Any]] = []
|
@@ -98,29 +67,34 @@ class ServerErrorCounter:
|
|
98
67
|
self.sentry_event_ids.clear()
|
99
68
|
return data
|
100
69
|
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
70
|
+
|
71
|
+
def get_exception_type(exception: BaseException) -> str:
|
72
|
+
exception_type = type(exception)
|
73
|
+
return f"{exception_type.__module__}.{exception_type.__qualname__}"
|
74
|
+
|
75
|
+
|
76
|
+
def get_truncated_exception_msg(exception: BaseException) -> str:
|
77
|
+
msg = str(exception).strip()
|
78
|
+
if len(msg) <= MAX_EXCEPTION_MSG_LENGTH:
|
79
|
+
return msg
|
80
|
+
suffix = "... (truncated)"
|
81
|
+
cutoff = MAX_EXCEPTION_MSG_LENGTH - len(suffix)
|
82
|
+
return msg[:cutoff] + suffix
|
83
|
+
|
84
|
+
|
85
|
+
def get_truncated_exception_traceback(exception: BaseException) -> str:
|
86
|
+
prefix = "... (truncated) ...\n"
|
87
|
+
cutoff = MAX_EXCEPTION_TRACEBACK_LENGTH - len(prefix)
|
88
|
+
lines = []
|
89
|
+
length = 0
|
90
|
+
if sys.version_info >= (3, 10):
|
91
|
+
traceback_lines = traceback.format_exception(exception)
|
92
|
+
else:
|
93
|
+
traceback_lines = traceback.format_exception(type(exception), exception, exception.__traceback__)
|
94
|
+
for line in traceback_lines[::-1]:
|
95
|
+
if length + len(line) > cutoff:
|
96
|
+
lines.append(prefix)
|
97
|
+
break
|
98
|
+
lines.append(line)
|
99
|
+
length += len(line)
|
100
|
+
return "".join(lines[::-1]).strip()
|
apitally/django.py
CHANGED
@@ -19,6 +19,7 @@ from apitally.client.logging import get_logger
|
|
19
19
|
from apitally.client.request_logging import (
|
20
20
|
BODY_TOO_LARGE,
|
21
21
|
MAX_BODY_SIZE,
|
22
|
+
RequestLogger,
|
22
23
|
RequestLoggingConfig,
|
23
24
|
)
|
24
25
|
from apitally.common import get_versions, parse_int
|
@@ -113,7 +114,7 @@ class ApitallyMiddleware:
|
|
113
114
|
)
|
114
115
|
|
115
116
|
def __call__(self, request: HttpRequest) -> HttpResponse:
|
116
|
-
if request.method is not None and request.method != "OPTIONS":
|
117
|
+
if self.client.enabled and request.method is not None and request.method != "OPTIONS":
|
117
118
|
timestamp = time.time()
|
118
119
|
request_size = parse_int(request.headers.get("Content-Length"))
|
119
120
|
request_body = b""
|
@@ -133,7 +134,12 @@ class ApitallyMiddleware:
|
|
133
134
|
else (len(response.content) if not response.streaming else None)
|
134
135
|
)
|
135
136
|
response_body = b""
|
136
|
-
|
137
|
+
response_content_type = response.get("Content-Type")
|
138
|
+
if (
|
139
|
+
self.capture_response_body
|
140
|
+
and not response.streaming
|
141
|
+
and RequestLogger.is_supported_content_type(response_content_type)
|
142
|
+
):
|
137
143
|
response_body = (
|
138
144
|
response.content if response_size is not None and response_size <= MAX_BODY_SIZE else BODY_TOO_LARGE
|
139
145
|
)
|
@@ -210,6 +216,7 @@ class ApitallyMiddleware:
|
|
210
216
|
"size": response_size,
|
211
217
|
"body": response_body,
|
212
218
|
},
|
219
|
+
exception=getattr(request, "unhandled_exception", None),
|
213
220
|
)
|
214
221
|
else:
|
215
222
|
response = self.get_response(request)
|
apitally/flask.py
CHANGED
@@ -17,6 +17,7 @@ from apitally.client.consumers import Consumer as ApitallyConsumer
|
|
17
17
|
from apitally.client.request_logging import (
|
18
18
|
BODY_TOO_LARGE,
|
19
19
|
MAX_BODY_SIZE,
|
20
|
+
RequestLogger,
|
20
21
|
RequestLoggingConfig,
|
21
22
|
)
|
22
23
|
from apitally.common import get_versions
|
@@ -74,6 +75,9 @@ class ApitallyMiddleware:
|
|
74
75
|
self.client.set_startup_data(data)
|
75
76
|
|
76
77
|
def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
|
78
|
+
if not self.client.enabled:
|
79
|
+
return self.wsgi_app(environ, start_response)
|
80
|
+
|
77
81
|
timestamp = time.time()
|
78
82
|
response_headers = Headers([])
|
79
83
|
status_code = 0
|
@@ -100,7 +104,8 @@ class ApitallyMiddleware:
|
|
100
104
|
response_time = time.perf_counter() - start_time
|
101
105
|
|
102
106
|
response_body = b""
|
103
|
-
|
107
|
+
response_content_type = response_headers.get("Content-Type")
|
108
|
+
if self.capture_response_body and RequestLogger.is_supported_content_type(response_content_type):
|
104
109
|
response_size = response_headers.get("Content-Length", type=int)
|
105
110
|
if response_size is not None and response_size > MAX_BODY_SIZE:
|
106
111
|
response_body = BODY_TOO_LARGE
|
@@ -185,6 +190,7 @@ class ApitallyMiddleware:
|
|
185
190
|
"size": response_size,
|
186
191
|
"body": response_body,
|
187
192
|
},
|
193
|
+
exception=g.unhandled_exception if "unhandled_exception" in g else None,
|
188
194
|
)
|
189
195
|
|
190
196
|
def get_path(self, environ: WSGIEnvironment) -> Optional[str]:
|
apitally/litestar.py
CHANGED
@@ -19,6 +19,7 @@ from apitally.client.consumers import Consumer as ApitallyConsumer
|
|
19
19
|
from apitally.client.request_logging import (
|
20
20
|
BODY_TOO_LARGE,
|
21
21
|
MAX_BODY_SIZE,
|
22
|
+
RequestLogger,
|
22
23
|
RequestLoggingConfig,
|
23
24
|
)
|
24
25
|
from apitally.common import get_versions, parse_int
|
@@ -86,7 +87,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
86
87
|
|
87
88
|
def middleware_factory(self, app: ASGIApp) -> ASGIApp:
|
88
89
|
async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
|
89
|
-
if scope["type"] == "http" and scope["method"] != "OPTIONS":
|
90
|
+
if self.client.enabled and scope["type"] == "http" and scope["method"] != "OPTIONS":
|
90
91
|
timestamp = time.time()
|
91
92
|
request = Request(scope)
|
92
93
|
request_size = parse_int(request.headers.get("Content-Length"))
|
@@ -99,6 +100,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
99
100
|
response_body_too_large = False
|
100
101
|
response_size: Optional[int] = None
|
101
102
|
response_chunked = False
|
103
|
+
response_content_type: Optional[str] = None
|
102
104
|
start_time = time.perf_counter()
|
103
105
|
|
104
106
|
async def receive_wrapper() -> Message:
|
@@ -119,6 +121,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
119
121
|
response_body, \
|
120
122
|
response_body_too_large, \
|
121
123
|
response_chunked, \
|
124
|
+
response_content_type, \
|
122
125
|
response_size
|
123
126
|
if message["type"] == "http.response.start":
|
124
127
|
response_time = time.perf_counter() - start_time
|
@@ -128,12 +131,17 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
128
131
|
response_headers.get("Transfer-Encoding") == "chunked"
|
129
132
|
or "Content-Length" not in response_headers
|
130
133
|
)
|
134
|
+
response_content_type = response_headers.get("Content-Type")
|
131
135
|
response_size = parse_int(response_headers.get("Content-Length")) if not response_chunked else 0
|
132
136
|
response_body_too_large = response_size is not None and response_size > MAX_BODY_SIZE
|
133
137
|
elif message["type"] == "http.response.body":
|
134
138
|
if response_chunked and response_size is not None:
|
135
139
|
response_size += len(message.get("body", b""))
|
136
|
-
if (
|
140
|
+
if (
|
141
|
+
(self.capture_response_body or response_status == 400)
|
142
|
+
and RequestLogger.is_supported_content_type(response_content_type)
|
143
|
+
and not response_body_too_large
|
144
|
+
):
|
137
145
|
response_body += message.get("body", b"")
|
138
146
|
if len(response_body) > MAX_BODY_SIZE:
|
139
147
|
response_body_too_large = True
|
@@ -243,6 +251,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
243
251
|
"size": response_size,
|
244
252
|
"body": response_body,
|
245
253
|
},
|
254
|
+
exception=request.state["exception"] if "exception" in request.state else None,
|
246
255
|
)
|
247
256
|
|
248
257
|
def get_path(self, request: Request) -> Optional[str]:
|
apitally/starlette.py
CHANGED
@@ -20,6 +20,7 @@ from apitally.client.consumers import Consumer as ApitallyConsumer
|
|
20
20
|
from apitally.client.request_logging import (
|
21
21
|
BODY_TOO_LARGE,
|
22
22
|
MAX_BODY_SIZE,
|
23
|
+
RequestLogger,
|
23
24
|
RequestLoggingConfig,
|
24
25
|
)
|
25
26
|
from apitally.common import get_versions, parse_int
|
@@ -73,7 +74,7 @@ class ApitallyMiddleware:
|
|
73
74
|
self.client.set_startup_data(data)
|
74
75
|
|
75
76
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
76
|
-
if scope["type"] == "http" and scope["method"] != "OPTIONS":
|
77
|
+
if self.client.enabled and scope["type"] == "http" and scope["method"] != "OPTIONS":
|
77
78
|
timestamp = time.time()
|
78
79
|
request = Request(scope)
|
79
80
|
request_size = parse_int(request.headers.get("Content-Length"))
|
@@ -86,6 +87,7 @@ class ApitallyMiddleware:
|
|
86
87
|
response_body_too_large = False
|
87
88
|
response_size: Optional[int] = None
|
88
89
|
response_chunked = False
|
90
|
+
response_content_type: Optional[str] = None
|
89
91
|
exception: Optional[BaseException] = None
|
90
92
|
start_time = time.perf_counter()
|
91
93
|
|
@@ -107,6 +109,7 @@ class ApitallyMiddleware:
|
|
107
109
|
response_body, \
|
108
110
|
response_body_too_large, \
|
109
111
|
response_chunked, \
|
112
|
+
response_content_type, \
|
110
113
|
response_size
|
111
114
|
if message["type"] == "http.response.start":
|
112
115
|
response_time = time.perf_counter() - start_time
|
@@ -116,12 +119,17 @@ class ApitallyMiddleware:
|
|
116
119
|
response_headers.get("Transfer-Encoding") == "chunked"
|
117
120
|
or "Content-Length" not in response_headers
|
118
121
|
)
|
122
|
+
response_content_type = response_headers.get("Content-Type")
|
119
123
|
response_size = parse_int(response_headers.get("Content-Length")) if not response_chunked else 0
|
120
124
|
response_body_too_large = response_size is not None and response_size > MAX_BODY_SIZE
|
121
125
|
elif message["type"] == "http.response.body":
|
122
126
|
if response_chunked and response_size is not None:
|
123
127
|
response_size += len(message.get("body", b""))
|
124
|
-
if (
|
128
|
+
if (
|
129
|
+
(self.capture_response_body or response_status == 422)
|
130
|
+
and RequestLogger.is_supported_content_type(response_content_type)
|
131
|
+
and not response_body_too_large
|
132
|
+
):
|
125
133
|
response_body += message.get("body", b"")
|
126
134
|
if len(response_body) > MAX_BODY_SIZE:
|
127
135
|
response_body_too_large = True
|
@@ -220,6 +228,7 @@ class ApitallyMiddleware:
|
|
220
228
|
"size": response_size,
|
221
229
|
"body": response_body,
|
222
230
|
},
|
231
|
+
exception=exception,
|
223
232
|
)
|
224
233
|
|
225
234
|
def get_path(self, request: Request, routes: Optional[list[BaseRoute]] = None) -> Optional[str]:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: apitally
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.15.0
|
4
4
|
Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
|
5
5
|
Project-URL: Homepage, https://apitally.io
|
6
6
|
Project-URL: Documentation, https://docs.apitally.io
|
@@ -62,37 +62,37 @@ Requires-Dist: starlette<1.0.0,>=0.21.0; extra == 'starlette'
|
|
62
62
|
Description-Content-Type: text/markdown
|
63
63
|
|
64
64
|
<p align="center">
|
65
|
-
<
|
66
|
-
<
|
67
|
-
|
68
|
-
|
69
|
-
|
65
|
+
<a href="https://apitally.io" target="_blank">
|
66
|
+
<picture>
|
67
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-vertical-dark.png">
|
68
|
+
<source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-vertical-light.png">
|
69
|
+
<img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-vertical-light.png" width="150">
|
70
|
+
</picture>
|
71
|
+
</a>
|
70
72
|
</p>
|
71
73
|
|
72
|
-
<p align="center"><b>
|
74
|
+
<p align="center"><b>Simple, privacy-focused API monitoring & analytics</b></p>
|
73
75
|
|
74
|
-
<p align="center"><i>Apitally helps you understand how your APIs are being used and alerts you when things go wrong.<br>
|
75
|
-
|
76
|
-
<p align="center">🔗 <b><a href="https://apitally.io" target="_blank">apitally.io</a></b></p>
|
76
|
+
<p align="center"><i>Apitally helps you understand how your APIs are being used and alerts you when things go wrong.<br>Just add two lines of code to your project to get started.</i></p>
|
77
|
+
<br>
|
77
78
|
|
78
79
|

|
79
80
|
|
80
81
|
---
|
81
82
|
|
82
|
-
# Apitally
|
83
|
+
# Apitally SDK for Python
|
83
84
|
|
84
85
|
[](https://github.com/apitally/apitally-py/actions)
|
85
86
|
[](https://codecov.io/gh/apitally/apitally-py)
|
86
87
|
[](https://pypi.org/project/apitally/)
|
87
88
|
|
88
|
-
This
|
89
|
-
frameworks:
|
89
|
+
This SDK for Apitally currently supports the following Python web frameworks:
|
90
90
|
|
91
91
|
- [FastAPI](https://docs.apitally.io/frameworks/fastapi)
|
92
|
-
- [Starlette](https://docs.apitally.io/frameworks/starlette)
|
93
|
-
- [Flask](https://docs.apitally.io/frameworks/flask)
|
94
|
-
- [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
|
95
92
|
- [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
|
93
|
+
- [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
|
94
|
+
- [Flask](https://docs.apitally.io/frameworks/flask)
|
95
|
+
- [Starlette](https://docs.apitally.io/frameworks/starlette)
|
96
96
|
- [Litestar](https://docs.apitally.io/frameworks/litestar)
|
97
97
|
|
98
98
|
Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
|
@@ -100,10 +100,29 @@ the 📚 [documentation](https://docs.apitally.io).
|
|
100
100
|
|
101
101
|
## Key features
|
102
102
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
103
|
+
### API analytics
|
104
|
+
|
105
|
+
Track traffic, error and performance metrics for your API, each endpoint and
|
106
|
+
individual API consumers, allowing you to make informed, data-driven engineering
|
107
|
+
and product decisions.
|
108
|
+
|
109
|
+
### Error tracking
|
110
|
+
|
111
|
+
Understand which validation rules in your endpoints cause client errors. Capture
|
112
|
+
error details and stack traces for 500 error responses, and have them linked to
|
113
|
+
Sentry issues automatically.
|
114
|
+
|
115
|
+
### Request logging
|
116
|
+
|
117
|
+
Drill down from insights to individual requests or use powerful filtering to
|
118
|
+
understand how consumers have interacted with your API. Configure exactly what
|
119
|
+
is included in the logs to meet your requirements.
|
120
|
+
|
121
|
+
### API monitoring & alerting
|
122
|
+
|
123
|
+
Get notified immediately if something isn't right using custom alerts, synthetic
|
124
|
+
uptime checks and heartbeat monitoring. Notifications can be delivered via
|
125
|
+
email, Slack or Microsoft Teams.
|
107
126
|
|
108
127
|
## Install
|
109
128
|
|
@@ -140,6 +159,25 @@ app.add_middleware(
|
|
140
159
|
)
|
141
160
|
```
|
142
161
|
|
162
|
+
### Django
|
163
|
+
|
164
|
+
This is an example of how to add the Apitally middleware to a Django Ninja or
|
165
|
+
Django REST Framework application. For further instructions, see our
|
166
|
+
[setup guide for Django](https://docs.apitally.io/frameworks/django).
|
167
|
+
|
168
|
+
In your Django `settings.py` file:
|
169
|
+
|
170
|
+
```python
|
171
|
+
MIDDLEWARE = [
|
172
|
+
"apitally.django.ApitallyMiddleware",
|
173
|
+
# Other middleware ...
|
174
|
+
]
|
175
|
+
APITALLY_MIDDLEWARE = {
|
176
|
+
"client_id": "your-client-id",
|
177
|
+
"env": "dev", # or "prod" etc.
|
178
|
+
}
|
179
|
+
```
|
180
|
+
|
143
181
|
### Flask
|
144
182
|
|
145
183
|
This is an example of how to add the Apitally middleware to a Flask application.
|
@@ -158,23 +196,22 @@ app.wsgi_app = ApitallyMiddleware(
|
|
158
196
|
)
|
159
197
|
```
|
160
198
|
|
161
|
-
###
|
162
|
-
|
163
|
-
This is an example of how to add the Apitally middleware to a Django Ninja or
|
164
|
-
Django REST Framework application. For further instructions, see our
|
165
|
-
[setup guide for Django](https://docs.apitally.io/frameworks/django).
|
199
|
+
### Starlette
|
166
200
|
|
167
|
-
|
201
|
+
This is an example of how to add the Apitally middleware to a Starlette
|
202
|
+
application. For further instructions, see our
|
203
|
+
[setup guide for Starlette](https://docs.apitally.io/frameworks/starlette).
|
168
204
|
|
169
205
|
```python
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
]
|
174
|
-
|
175
|
-
|
176
|
-
"
|
177
|
-
|
206
|
+
from starlette.applications import Starlette
|
207
|
+
from apitally.starlette import ApitallyMiddleware
|
208
|
+
|
209
|
+
app = Starlette(routes=[...])
|
210
|
+
app.add_middleware(
|
211
|
+
ApitallyMiddleware,
|
212
|
+
client_id="your-client-id",
|
213
|
+
env="dev", # or "prod" etc.
|
214
|
+
)
|
178
215
|
```
|
179
216
|
|
180
217
|
### Litestar
|
@@ -0,0 +1,25 @@
|
|
1
|
+
apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
|
2
|
+
apitally/common.py,sha256=Y8MRuTUHFUeQkcDrCLUxnqIPRpYIiW8S43T0QUab-_A,1267
|
3
|
+
apitally/django.py,sha256=1gLW5aVbIobjHoa5OzC3K1E85mC2a00PpJIAR9ATko4,16937
|
4
|
+
apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
5
|
+
apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
6
|
+
apitally/fastapi.py,sha256=IfKfgsmIY8_AtnuMTW2sW4qnkya61CAE2vBoIpcc9tk,169
|
7
|
+
apitally/flask.py,sha256=p_u33_FQq2i5AebWB8wYxXX0CPhcX8OJHGWj5dR4sPY,9622
|
8
|
+
apitally/litestar.py,sha256=HThNH-gAnFtLyVU4Eh8L_obd0f3TNLYoTZ8IGgz1ZKE,13610
|
9
|
+
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
|
+
apitally/starlette.py,sha256=liK1KSEiQolHm1cNfJm6tkmE3uujoVqj8b-2prUVa3o,13277
|
11
|
+
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
+
apitally/client/client_asyncio.py,sha256=9mdi9Hmb6-xn7dNdwP84e4PNAHGg2bYdMEgIfPUAtcQ,7003
|
13
|
+
apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbgw,3799
|
14
|
+
apitally/client/client_threading.py,sha256=7JPu2Uulev7X2RiSLx4HJYfvAP6Z5zB_yuSevMfQC7I,7389
|
15
|
+
apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
|
16
|
+
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
17
|
+
apitally/client/request_logging.py,sha256=OL1jlpHXYpZw2VKRekgNSwZQ0qZJeiiJxKeTYrmP22g,13913
|
18
|
+
apitally/client/requests.py,sha256=RdJyvIqQGVHvS-wjpAPUwcO7byOJ6jO8dYqNTU2Furg,3685
|
19
|
+
apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
|
20
|
+
apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
|
21
|
+
apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
|
22
|
+
apitally-0.15.0.dist-info/METADATA,sha256=CZxfxtM3p1RBy5ICdnuhjAWt4CESHhKtAIFEFtKCbjw,8643
|
23
|
+
apitally-0.15.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
24
|
+
apitally-0.15.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
25
|
+
apitally-0.15.0.dist-info/RECORD,,
|
apitally-0.14.5.dist-info/RECORD
DELETED
@@ -1,24 +0,0 @@
|
|
1
|
-
apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
|
2
|
-
apitally/common.py,sha256=Y8MRuTUHFUeQkcDrCLUxnqIPRpYIiW8S43T0QUab-_A,1267
|
3
|
-
apitally/django.py,sha256=xM8zyH8LYr4BxAlhvfpUyau4OF5i_TMcISs3eJ7xvpY,16621
|
4
|
-
apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
5
|
-
apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
6
|
-
apitally/fastapi.py,sha256=IfKfgsmIY8_AtnuMTW2sW4qnkya61CAE2vBoIpcc9tk,169
|
7
|
-
apitally/flask.py,sha256=Th5LsMsTKkWERPrKfSWPhzrp99tg0pDtKXgtlVLx3eo,9279
|
8
|
-
apitally/litestar.py,sha256=hAH2-OVVXBDVY8LopfIGv30yYwi-71tSEsKd6648CYc,13098
|
9
|
-
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
|
-
apitally/starlette.py,sha256=VaT4-QVSYC0YX1U5kVI-dGROEd64IbjYU5lx5N16yf8,12852
|
11
|
-
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
-
apitally/client/client_asyncio.py,sha256=2RibaadLAEdl2i0yAb4dSEDq_r46w6HPFpZVzvt59aQ,6941
|
13
|
-
apitally/client/client_base.py,sha256=dXsaB7scd0wCd_DcdiUvNlDLZ2pWbWsGDnJ4fLYvJGg,3771
|
14
|
-
apitally/client/client_threading.py,sha256=Y8LlA_8LFHAuXym-T4bY_XFqXxiIDw9qFqO6K3FbZy0,7356
|
15
|
-
apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
|
16
|
-
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
17
|
-
apitally/client/request_logging.py,sha256=kKEPfNVK-T-maz4pkeyu805rZ4B2MFsjWjyu0PeW1WE,12973
|
18
|
-
apitally/client/requests.py,sha256=RdJyvIqQGVHvS-wjpAPUwcO7byOJ6jO8dYqNTU2Furg,3685
|
19
|
-
apitally/client/server_errors.py,sha256=axEhOxqV5SWjk0QCZTLVv2UMIaTfqPc81Typ4DXt66A,4646
|
20
|
-
apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
|
21
|
-
apitally-0.14.5.dist-info/METADATA,sha256=YlHcMcEDL-0mhhfXhQPS1PbbsobvZJhn8Ie8jiPfUpA,7570
|
22
|
-
apitally-0.14.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
23
|
-
apitally-0.14.5.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
24
|
-
apitally-0.14.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|