apitally 0.18.2__py3-none-any.whl → 0.19.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/request_logging.py +147 -43
- {apitally-0.18.2.dist-info → apitally-0.19.0.dist-info}/METADATA +4 -3
- {apitally-0.18.2.dist-info → apitally-0.19.0.dist-info}/RECORD +5 -5
- {apitally-0.18.2.dist-info → apitally-0.19.0.dist-info}/WHEEL +0 -0
- {apitally-0.18.2.dist-info → apitally-0.19.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,7 @@ import tempfile
|
|
5
5
|
import threading
|
6
6
|
import time
|
7
7
|
from collections import deque
|
8
|
+
from contextlib import suppress
|
8
9
|
from dataclasses import dataclass, field
|
9
10
|
from functools import lru_cache
|
10
11
|
from io import BufferedReader
|
@@ -22,6 +23,12 @@ from apitally.client.server_errors import (
|
|
22
23
|
)
|
23
24
|
|
24
25
|
|
26
|
+
try:
|
27
|
+
from typing import NotRequired
|
28
|
+
except ImportError:
|
29
|
+
from typing_extensions import NotRequired
|
30
|
+
|
31
|
+
|
25
32
|
logger = get_logger(__name__)
|
26
33
|
|
27
34
|
MAX_BODY_SIZE = 50_000 # 50 KB (uncompressed)
|
@@ -31,7 +38,12 @@ MAX_FILES_IN_DEQUE = 50
|
|
31
38
|
BODY_TOO_LARGE = b"<body too large>"
|
32
39
|
BODY_MASKED = b"<masked>"
|
33
40
|
MASKED = "******"
|
34
|
-
ALLOWED_CONTENT_TYPES = [
|
41
|
+
ALLOWED_CONTENT_TYPES = [
|
42
|
+
"application/json",
|
43
|
+
"application/problem+json",
|
44
|
+
"application/vnd.api+json",
|
45
|
+
"text/plain",
|
46
|
+
]
|
35
47
|
EXCLUDE_PATH_PATTERNS = [
|
36
48
|
r"/_?healthz?$",
|
37
49
|
r"/_?health[\-_]?checks?$",
|
@@ -61,6 +73,16 @@ MASK_HEADER_PATTERNS = [
|
|
61
73
|
r"token",
|
62
74
|
r"cookie",
|
63
75
|
]
|
76
|
+
MASK_BODY_FIELD_PATTERNS = [
|
77
|
+
r"password",
|
78
|
+
r"pwd",
|
79
|
+
r"token",
|
80
|
+
r"secret",
|
81
|
+
r"auth",
|
82
|
+
r"card[\-_ ]number",
|
83
|
+
r"ccv",
|
84
|
+
r"ssn",
|
85
|
+
]
|
64
86
|
|
65
87
|
|
66
88
|
class RequestDict(TypedDict):
|
@@ -82,6 +104,20 @@ class ResponseDict(TypedDict):
|
|
82
104
|
body: Optional[bytes]
|
83
105
|
|
84
106
|
|
107
|
+
class ExceptionDict(TypedDict):
|
108
|
+
type: str
|
109
|
+
message: str
|
110
|
+
traceback: str
|
111
|
+
sentry_event_id: NotRequired[str]
|
112
|
+
|
113
|
+
|
114
|
+
class RequestLogItem(TypedDict):
|
115
|
+
uuid: str
|
116
|
+
request: RequestDict
|
117
|
+
response: ResponseDict
|
118
|
+
exception: NotRequired[ExceptionDict]
|
119
|
+
|
120
|
+
|
85
121
|
@dataclass
|
86
122
|
class RequestLoggingConfig:
|
87
123
|
"""
|
@@ -97,6 +133,7 @@ class RequestLoggingConfig:
|
|
97
133
|
log_exception: Whether to log unhandled exceptions in case of server errors
|
98
134
|
mask_query_params: Query parameter names to mask in logs. Expects regular expressions.
|
99
135
|
mask_headers: Header names to mask in logs. Expects regular expressions.
|
136
|
+
mask_body_fields: Body fields to mask in logs. Expects regular expressions.
|
100
137
|
mask_request_body_callback: Callback to mask the request body. Expects `request: RequestDict` as argument and returns the masked body as bytes or None.
|
101
138
|
mask_response_body_callback: Callback to mask the response body. Expects `request: RequestDict` and `response: ResponseDict` as arguments and returns the masked body as bytes or None.
|
102
139
|
exclude_paths: Paths to exclude from logging. Expects regular expressions.
|
@@ -112,6 +149,7 @@ class RequestLoggingConfig:
|
|
112
149
|
log_exception: bool = True
|
113
150
|
mask_query_params: List[str] = field(default_factory=list)
|
114
151
|
mask_headers: List[str] = field(default_factory=list)
|
152
|
+
mask_body_fields: List[str] = field(default_factory=list)
|
115
153
|
mask_request_body_callback: Optional[Callable[[RequestDict], Optional[bytes]]] = None
|
116
154
|
mask_response_body_callback: Optional[Callable[[RequestDict, ResponseDict], Optional[bytes]]] = None
|
117
155
|
exclude_paths: List[str] = field(default_factory=list)
|
@@ -161,7 +199,8 @@ class RequestLogger:
|
|
161
199
|
self.config = config or RequestLoggingConfig()
|
162
200
|
self.enabled = self.config.enabled and _check_writable_fs()
|
163
201
|
self.serialize = _get_json_serializer()
|
164
|
-
self.
|
202
|
+
self.deserialize = _get_json_deserializer()
|
203
|
+
self.write_deque: deque[RequestLogItem] = deque([], MAX_REQUESTS_IN_DEQUE)
|
165
204
|
self.file_deque: deque[TempGzipFile] = deque([])
|
166
205
|
self.file: Optional[TempGzipFile] = None
|
167
206
|
self.lock = threading.Lock()
|
@@ -172,10 +211,14 @@ class RequestLogger:
|
|
172
211
|
return self.file.size if self.file is not None else 0
|
173
212
|
|
174
213
|
def log_request(
|
175
|
-
self,
|
214
|
+
self,
|
215
|
+
request: RequestDict,
|
216
|
+
response: ResponseDict,
|
217
|
+
exception: Optional[BaseException] = None,
|
176
218
|
) -> None:
|
177
219
|
if not self.enabled or self.suspend_until is not None:
|
178
220
|
return
|
221
|
+
|
179
222
|
parsed_url = urlparse(request["url"])
|
180
223
|
user_agent = self._get_user_agent(request["headers"])
|
181
224
|
if (
|
@@ -185,55 +228,20 @@ class RequestLogger:
|
|
185
228
|
):
|
186
229
|
return
|
187
230
|
|
188
|
-
query = self._mask_query_params(parsed_url.query) if self.config.log_query_params else ""
|
189
|
-
request["url"] = urlunparse(parsed_url._replace(query=query))
|
190
|
-
|
191
231
|
if not self.config.log_request_body or not self._has_supported_content_type(request["headers"]):
|
192
232
|
request["body"] = None
|
193
|
-
elif (
|
194
|
-
self.config.mask_request_body_callback is not None
|
195
|
-
and request["body"] is not None
|
196
|
-
and request["body"] != BODY_TOO_LARGE
|
197
|
-
):
|
198
|
-
try:
|
199
|
-
request["body"] = self.config.mask_request_body_callback(request)
|
200
|
-
except Exception: # pragma: no cover
|
201
|
-
logger.exception("User-provided mask_request_body_callback function raised an exception")
|
202
|
-
request["body"] = None
|
203
|
-
if request["body"] is None:
|
204
|
-
request["body"] = BODY_MASKED
|
205
|
-
if request["body"] is not None and len(request["body"]) > MAX_BODY_SIZE:
|
206
|
-
request["body"] = BODY_TOO_LARGE
|
207
|
-
|
208
233
|
if not self.config.log_response_body or not self._has_supported_content_type(response["headers"]):
|
209
234
|
response["body"] = None
|
210
|
-
elif (
|
211
|
-
self.config.mask_response_body_callback is not None
|
212
|
-
and response["body"] is not None
|
213
|
-
and response["body"] != BODY_TOO_LARGE
|
214
|
-
):
|
215
|
-
try:
|
216
|
-
response["body"] = self.config.mask_response_body_callback(request, response)
|
217
|
-
except Exception: # pragma: no cover
|
218
|
-
logger.exception("User-provided mask_response_body_callback function raised an exception")
|
219
|
-
response["body"] = None
|
220
|
-
if response["body"] is None:
|
221
|
-
response["body"] = BODY_MASKED
|
222
|
-
if response["body"] is not None and len(response["body"]) > MAX_BODY_SIZE:
|
223
|
-
response["body"] = BODY_TOO_LARGE
|
224
|
-
|
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
235
|
|
228
236
|
if request["size"] is not None and request["size"] < 0:
|
229
237
|
request["size"] = None
|
230
238
|
if response["size"] is not None and response["size"] < 0:
|
231
239
|
response["size"] = None
|
232
240
|
|
233
|
-
item:
|
241
|
+
item: RequestLogItem = {
|
234
242
|
"uuid": str(uuid4()),
|
235
|
-
"request":
|
236
|
-
"response":
|
243
|
+
"request": request,
|
244
|
+
"response": response,
|
237
245
|
}
|
238
246
|
if exception is not None and self.config.log_exception:
|
239
247
|
item["exception"] = {
|
@@ -242,6 +250,7 @@ class RequestLogger:
|
|
242
250
|
"traceback": get_truncated_exception_traceback(exception),
|
243
251
|
}
|
244
252
|
get_sentry_event_id_async(lambda event_id: item["exception"].update({"sentry_event_id": event_id}))
|
253
|
+
|
245
254
|
self.write_deque.append(item)
|
246
255
|
|
247
256
|
def write_to_file(self) -> None:
|
@@ -253,6 +262,9 @@ class RequestLogger:
|
|
253
262
|
while True:
|
254
263
|
try:
|
255
264
|
item = self.write_deque.popleft()
|
265
|
+
item = self._apply_masking(item)
|
266
|
+
item["request"] = _skip_empty_values(item["request"]) # type: ignore[typeddict-item]
|
267
|
+
item["response"] = _skip_empty_values(item["response"]) # type: ignore[typeddict-item]
|
256
268
|
self.file.write_line(self.serialize(item))
|
257
269
|
except IndexError:
|
258
270
|
break
|
@@ -307,6 +319,67 @@ class RequestLogger:
|
|
307
319
|
def _should_exclude_user_agent(self, user_agent: Optional[str]) -> bool:
|
308
320
|
return self._match_patterns(user_agent, EXCLUDE_USER_AGENT_PATTERNS) if user_agent is not None else False
|
309
321
|
|
322
|
+
def _apply_masking(self, data: RequestLogItem) -> RequestLogItem:
|
323
|
+
# Apply user-provided mask_request_body_callback function
|
324
|
+
if (
|
325
|
+
self.config.mask_request_body_callback is not None
|
326
|
+
and data["request"]["body"] is not None
|
327
|
+
and data["request"]["body"] != BODY_TOO_LARGE
|
328
|
+
):
|
329
|
+
try:
|
330
|
+
data["request"]["body"] = self.config.mask_request_body_callback(data["request"])
|
331
|
+
except Exception: # pragma: no cover
|
332
|
+
logger.exception("User-provided mask_request_body_callback function raised an exception")
|
333
|
+
data["request"]["body"] = None
|
334
|
+
if data["request"]["body"] is None:
|
335
|
+
data["request"]["body"] = BODY_MASKED
|
336
|
+
|
337
|
+
# Apply user-provided mask_response_body_callback function
|
338
|
+
if (
|
339
|
+
self.config.mask_response_body_callback is not None
|
340
|
+
and data["response"]["body"] is not None
|
341
|
+
and data["response"]["body"] != BODY_TOO_LARGE
|
342
|
+
):
|
343
|
+
try:
|
344
|
+
data["response"]["body"] = self.config.mask_response_body_callback(data["request"], data["response"])
|
345
|
+
except Exception: # pragma: no cover
|
346
|
+
logger.exception("User-provided mask_response_body_callback function raised an exception")
|
347
|
+
data["response"]["body"] = None
|
348
|
+
if data["response"]["body"] is None:
|
349
|
+
data["response"]["body"] = BODY_MASKED
|
350
|
+
|
351
|
+
# Check request and response body sizes
|
352
|
+
if data["request"]["body"] is not None and len(data["request"]["body"]) > MAX_BODY_SIZE:
|
353
|
+
data["request"]["body"] = BODY_TOO_LARGE
|
354
|
+
if data["response"]["body"] is not None and len(data["response"]["body"]) > MAX_BODY_SIZE:
|
355
|
+
data["response"]["body"] = BODY_TOO_LARGE
|
356
|
+
|
357
|
+
# Mask request and response body fields
|
358
|
+
for key in ("request", "response"):
|
359
|
+
if data[key]["body"] is None or data[key]["body"] == BODY_TOO_LARGE or data[key]["body"] == BODY_MASKED:
|
360
|
+
continue
|
361
|
+
body = data[key]["body"]
|
362
|
+
body_is_json = self._has_json_content_type(data[key]["headers"])
|
363
|
+
if body is not None and (body_is_json is None or body_is_json):
|
364
|
+
with suppress(Exception):
|
365
|
+
masked_body = self._mask_body(self.deserialize(body))
|
366
|
+
data[key]["body"] = self.serialize(masked_body)
|
367
|
+
|
368
|
+
# Mask request and response headers
|
369
|
+
data["request"]["headers"] = (
|
370
|
+
self._mask_headers(data["request"]["headers"]) if self.config.log_request_headers else []
|
371
|
+
)
|
372
|
+
data["response"]["headers"] = (
|
373
|
+
self._mask_headers(data["response"]["headers"]) if self.config.log_response_headers else []
|
374
|
+
)
|
375
|
+
|
376
|
+
# Mask query params
|
377
|
+
parsed_url = urlparse(data["request"]["url"])
|
378
|
+
query = self._mask_query_params(parsed_url.query) if self.config.log_query_params else ""
|
379
|
+
data["request"]["url"] = urlunparse(parsed_url._replace(query=query))
|
380
|
+
|
381
|
+
return data
|
382
|
+
|
310
383
|
def _mask_query_params(self, query: str) -> str:
|
311
384
|
query_params = parse_qsl(query)
|
312
385
|
masked_query_params = [(k, v if not self._should_mask_query_param(k) else MASKED) for k, v in query_params]
|
@@ -315,6 +388,16 @@ class RequestLogger:
|
|
315
388
|
def _mask_headers(self, headers: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
316
389
|
return [(k, v if not self._should_mask_header(k) else MASKED) for k, v in headers]
|
317
390
|
|
391
|
+
def _mask_body(self, data: Any) -> Any:
|
392
|
+
if isinstance(data, dict):
|
393
|
+
return {
|
394
|
+
k: (MASKED if isinstance(v, str) and self._should_mask_body_field(k) else self._mask_body(v))
|
395
|
+
for k, v in data.items()
|
396
|
+
}
|
397
|
+
if isinstance(data, list):
|
398
|
+
return [self._mask_body(item) for item in data]
|
399
|
+
return data
|
400
|
+
|
318
401
|
@lru_cache(maxsize=100)
|
319
402
|
def _should_mask_query_param(self, query_param_name: str) -> bool:
|
320
403
|
patterns = self.config.mask_query_params + MASK_QUERY_PARAM_PATTERNS
|
@@ -325,6 +408,11 @@ class RequestLogger:
|
|
325
408
|
patterns = self.config.mask_headers + MASK_HEADER_PATTERNS
|
326
409
|
return self._match_patterns(header_name, patterns)
|
327
410
|
|
411
|
+
@lru_cache(maxsize=100)
|
412
|
+
def _should_mask_body_field(self, field_name: str) -> bool:
|
413
|
+
patterns = self.config.mask_body_fields + MASK_BODY_FIELD_PATTERNS
|
414
|
+
return self._match_patterns(field_name, patterns)
|
415
|
+
|
328
416
|
@staticmethod
|
329
417
|
def _match_patterns(value: str, patterns: List[str]) -> bool:
|
330
418
|
for pattern in patterns:
|
@@ -338,12 +426,17 @@ class RequestLogger:
|
|
338
426
|
|
339
427
|
@staticmethod
|
340
428
|
def _has_supported_content_type(headers: List[Tuple[str, str]]) -> bool:
|
341
|
-
content_type = next((v for k, v in headers if k.lower() == "content-type"), None)
|
429
|
+
content_type = next((v.lower() for k, v in headers if k.lower() == "content-type"), None)
|
342
430
|
return RequestLogger.is_supported_content_type(content_type)
|
343
431
|
|
432
|
+
@staticmethod
|
433
|
+
def _has_json_content_type(headers: List[Tuple[str, str]]) -> Optional[bool]:
|
434
|
+
content_type = next((v.lower() for k, v in headers if k.lower() == "content-type"), None)
|
435
|
+
return None if content_type is None else (re.search(r"\bjson\b", content_type) is not None)
|
436
|
+
|
344
437
|
@staticmethod
|
345
438
|
def is_supported_content_type(content_type: Optional[str]) -> bool:
|
346
|
-
return content_type is not None and any(content_type.startswith(t) for t in ALLOWED_CONTENT_TYPES)
|
439
|
+
return content_type is not None and any(content_type.lower().startswith(t) for t in ALLOWED_CONTENT_TYPES)
|
347
440
|
|
348
441
|
|
349
442
|
def _check_writable_fs() -> bool:
|
@@ -377,6 +470,17 @@ def _get_json_serializer() -> Callable[[Any], bytes]:
|
|
377
470
|
return json_dumps
|
378
471
|
|
379
472
|
|
473
|
+
def _get_json_deserializer() -> Callable[[bytes], Any]:
|
474
|
+
try:
|
475
|
+
import orjson # type: ignore
|
476
|
+
|
477
|
+
return orjson.loads
|
478
|
+
except ImportError:
|
479
|
+
import json
|
480
|
+
|
481
|
+
return json.loads
|
482
|
+
|
483
|
+
|
380
484
|
def _skip_empty_values(data: Mapping) -> Dict:
|
381
485
|
return {
|
382
486
|
k: v for k, v in data.items() if v is not None and not (isinstance(v, (list, dict, bytes, str)) and len(v) == 0)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: apitally
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.19.0
|
4
4
|
Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette, Litestar and BlackSheep.
|
5
5
|
Project-URL: Homepage, https://apitally.io
|
6
6
|
Project-URL: Documentation, https://docs.apitally.io
|
@@ -32,6 +32,7 @@ Classifier: Topic :: System :: Monitoring
|
|
32
32
|
Classifier: Typing :: Typed
|
33
33
|
Requires-Python: <4.0,>=3.9
|
34
34
|
Requires-Dist: backoff>=2.0.0
|
35
|
+
Requires-Dist: typing-extensions>=4.0.0; python_version < '3.11'
|
35
36
|
Provides-Extra: blacksheep
|
36
37
|
Requires-Dist: blacksheep>=2; extra == 'blacksheep'
|
37
38
|
Requires-Dist: httpx>=0.22.0; extra == 'blacksheep'
|
@@ -73,8 +74,8 @@ Description-Content-Type: text/markdown
|
|
73
74
|
</picture>
|
74
75
|
</a>
|
75
76
|
</p>
|
76
|
-
<p align="center"><b>
|
77
|
-
<p align="center" style="color: #ccc;">
|
77
|
+
<p align="center"><b>API monitoring & analytics made simple</b></p>
|
78
|
+
<p align="center" style="color: #ccc;">Real-time metrics, request logs, and alerts for your APIs — with just a few lines of code.</p>
|
78
79
|
<br>
|
79
80
|
<img alt="Apitally screenshots" src="https://assets.apitally.io/screenshots/overview.png">
|
80
81
|
<br>
|
@@ -15,12 +15,12 @@ apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbg
|
|
15
15
|
apitally/client/client_threading.py,sha256=sxMRcxRgk1SxJjSq-qpIcDVmD3Q7Kv4CVT5zEUVt0KM,7257
|
16
16
|
apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
|
17
17
|
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
18
|
-
apitally/client/request_logging.py,sha256=
|
18
|
+
apitally/client/request_logging.py,sha256=yCF_CH8isp3gpQRVgakk6DNV60mtPZWG6z45pUNACOM,17644
|
19
19
|
apitally/client/requests.py,sha256=SDptGOg9XvaEKFj2o3oxJz-JAuZzUrqpHnbOQixf99o,3794
|
20
20
|
apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
|
21
21
|
apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
|
22
22
|
apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
|
23
|
-
apitally-0.
|
24
|
-
apitally-0.
|
25
|
-
apitally-0.
|
26
|
-
apitally-0.
|
23
|
+
apitally-0.19.0.dist-info/METADATA,sha256=9hbiC_z4DINCao5gg0F0Lqd9NVR8hZqNnnjpXkg9lnI,9316
|
24
|
+
apitally-0.19.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
25
|
+
apitally-0.19.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
26
|
+
apitally-0.19.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|