apitally 0.18.1__tar.gz → 0.19.0__tar.gz

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.
Files changed (56) hide show
  1. {apitally-0.18.1 → apitally-0.19.0}/.github/workflows/publish.yaml +2 -2
  2. {apitally-0.18.1 → apitally-0.19.0}/.github/workflows/tests.yaml +4 -4
  3. {apitally-0.18.1 → apitally-0.19.0}/.pre-commit-config.yaml +2 -2
  4. apitally-0.19.0/.tool-versions +1 -0
  5. apitally-0.19.0/Makefile +16 -0
  6. {apitally-0.18.1 → apitally-0.19.0}/PKG-INFO +10 -13
  7. {apitally-0.18.1 → apitally-0.19.0}/README.md +8 -12
  8. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/request_logging.py +149 -45
  9. {apitally-0.18.1 → apitally-0.19.0}/apitally/django.py +102 -103
  10. {apitally-0.18.1 → apitally-0.19.0}/pyproject.toml +7 -4
  11. {apitally-0.18.1 → apitally-0.19.0}/tests/test_client_request_logging.py +141 -22
  12. {apitally-0.18.1 → apitally-0.19.0}/uv.lock +470 -483
  13. apitally-0.18.1/.tool-versions +0 -1
  14. apitally-0.18.1/Makefile +0 -16
  15. {apitally-0.18.1 → apitally-0.19.0}/.github/workflows/summary.yaml +0 -0
  16. {apitally-0.18.1 → apitally-0.19.0}/.gitignore +0 -0
  17. {apitally-0.18.1 → apitally-0.19.0}/LICENSE +0 -0
  18. {apitally-0.18.1 → apitally-0.19.0}/apitally/__init__.py +0 -0
  19. {apitally-0.18.1 → apitally-0.19.0}/apitally/blacksheep.py +0 -0
  20. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/__init__.py +0 -0
  21. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/client_asyncio.py +0 -0
  22. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/client_base.py +0 -0
  23. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/client_threading.py +0 -0
  24. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/consumers.py +0 -0
  25. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/logging.py +0 -0
  26. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/requests.py +0 -0
  27. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/sentry.py +0 -0
  28. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/server_errors.py +0 -0
  29. {apitally-0.18.1 → apitally-0.19.0}/apitally/client/validation_errors.py +0 -0
  30. {apitally-0.18.1 → apitally-0.19.0}/apitally/common.py +0 -0
  31. {apitally-0.18.1 → apitally-0.19.0}/apitally/django_ninja.py +0 -0
  32. {apitally-0.18.1 → apitally-0.19.0}/apitally/django_rest_framework.py +0 -0
  33. {apitally-0.18.1 → apitally-0.19.0}/apitally/fastapi.py +0 -0
  34. {apitally-0.18.1 → apitally-0.19.0}/apitally/flask.py +0 -0
  35. {apitally-0.18.1 → apitally-0.19.0}/apitally/litestar.py +0 -0
  36. {apitally-0.18.1 → apitally-0.19.0}/apitally/py.typed +0 -0
  37. {apitally-0.18.1 → apitally-0.19.0}/apitally/starlette.py +0 -0
  38. {apitally-0.18.1 → apitally-0.19.0}/renovate.json +0 -0
  39. {apitally-0.18.1 → apitally-0.19.0}/tests/__init__.py +0 -0
  40. {apitally-0.18.1 → apitally-0.19.0}/tests/conftest.py +0 -0
  41. {apitally-0.18.1 → apitally-0.19.0}/tests/constants.py +0 -0
  42. {apitally-0.18.1 → apitally-0.19.0}/tests/django_ninja_urls.py +0 -0
  43. {apitally-0.18.1 → apitally-0.19.0}/tests/django_rest_framework_urls.py +0 -0
  44. {apitally-0.18.1 → apitally-0.19.0}/tests/test_blacksheep.py +0 -0
  45. {apitally-0.18.1 → apitally-0.19.0}/tests/test_client_asyncio.py +0 -0
  46. {apitally-0.18.1 → apitally-0.19.0}/tests/test_client_consumers.py +0 -0
  47. {apitally-0.18.1 → apitally-0.19.0}/tests/test_client_requests.py +0 -0
  48. {apitally-0.18.1 → apitally-0.19.0}/tests/test_client_server_errors.py +0 -0
  49. {apitally-0.18.1 → apitally-0.19.0}/tests/test_client_threading.py +0 -0
  50. {apitally-0.18.1 → apitally-0.19.0}/tests/test_client_validation_errors.py +0 -0
  51. {apitally-0.18.1 → apitally-0.19.0}/tests/test_django_ninja.py +0 -0
  52. {apitally-0.18.1 → apitally-0.19.0}/tests/test_django_rest_framework.py +0 -0
  53. {apitally-0.18.1 → apitally-0.19.0}/tests/test_fastapi.py +0 -0
  54. {apitally-0.18.1 → apitally-0.19.0}/tests/test_flask.py +0 -0
  55. {apitally-0.18.1 → apitally-0.19.0}/tests/test_litestar.py +0 -0
  56. {apitally-0.18.1 → apitally-0.19.0}/tests/test_starlette.py +0 -0
@@ -14,9 +14,9 @@ jobs:
14
14
  with:
15
15
  fetch-depth: 0
16
16
  - name: Install uv
17
- uses: astral-sh/setup-uv@v5
17
+ uses: astral-sh/setup-uv@v6
18
18
  with:
19
- version: "0.7.3"
19
+ version: "0.7.13"
20
20
  enable-cache: true
21
21
  - name: Build package
22
22
  run: uv build
@@ -24,9 +24,9 @@ jobs:
24
24
  steps:
25
25
  - uses: actions/checkout@v4
26
26
  - name: Install uv
27
- uses: astral-sh/setup-uv@v5
27
+ uses: astral-sh/setup-uv@v6
28
28
  with:
29
- version: "0.7.3"
29
+ version: "0.7.13"
30
30
  enable-cache: true
31
31
  - name: Install Python
32
32
  run: uv python install 3.13
@@ -79,9 +79,9 @@ jobs:
79
79
  steps:
80
80
  - uses: actions/checkout@v4
81
81
  - name: Install uv
82
- uses: astral-sh/setup-uv@v5
82
+ uses: astral-sh/setup-uv@v6
83
83
  with:
84
- version: "0.7.3"
84
+ version: "0.7.13"
85
85
  enable-cache: true
86
86
  - name: Install Python
87
87
  run: uv python install ${{ matrix.python }}
@@ -8,12 +8,12 @@ repos:
8
8
  - id: trailing-whitespace
9
9
  - id: mixed-line-ending
10
10
  - repo: https://github.com/charliermarsh/ruff-pre-commit
11
- rev: v0.11.11
11
+ rev: v0.12.0
12
12
  hooks:
13
13
  - id: ruff
14
14
  args: ["--fix", "--exit-non-zero-on-fix"]
15
15
  - id: ruff-format
16
16
  - repo: https://github.com/astral-sh/uv-pre-commit
17
- rev: 0.7.8
17
+ rev: 0.7.13
18
18
  hooks:
19
19
  - id: uv-lock
@@ -0,0 +1 @@
1
+ uv 0.7.13
@@ -0,0 +1,16 @@
1
+ .PHONY: format check test test-coverage
2
+
3
+ format:
4
+ uv run ruff check apitally tests --fix --select I
5
+ uv run ruff format apitally tests
6
+
7
+ check:
8
+ uv run ruff check apitally tests
9
+ uv run ruff format --diff apitally tests
10
+ uv run mypy --install-types --non-interactive apitally tests
11
+
12
+ test:
13
+ uv run pytest -v --tb=short
14
+
15
+ test-coverage:
16
+ uv run pytest -v --tb=short --cov --cov-report=xml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apitally
3
- Version: 0.18.1
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'
@@ -67,21 +68,17 @@ Description-Content-Type: text/markdown
67
68
  <p align="center">
68
69
  <a href="https://apitally.io" target="_blank">
69
70
  <picture>
70
- <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-vertical-dark.png">
71
- <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-vertical-light.png">
72
- <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-vertical-light.png" width="150">
71
+ <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-horizontal-new-dark.png">
72
+ <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-horizontal-new-light.png">
73
+ <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-horizontal-new-light.png" width="220">
73
74
  </picture>
74
75
  </a>
75
76
  </p>
76
-
77
- <p align="center"><b>Simple, privacy-focused API monitoring & analytics</b></p>
78
-
79
- <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
+ <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>
79
+ <br>
80
+ <img alt="Apitally screenshots" src="https://assets.apitally.io/screenshots/overview.png">
80
81
  <br>
81
-
82
- ![Apitally screenshots](https://assets.apitally.io/screenshots/overview.png)
83
-
84
- ---
85
82
 
86
83
  # Apitally SDK for Python
87
84
 
@@ -138,7 +135,7 @@ pip install apitally[fastapi]
138
135
  ```
139
136
 
140
137
  The available extras are: `fastapi`, `flask`, `django_rest_framework`,
141
- `django_ninja`, `starlette` and `litestar`.
138
+ `django_ninja`, `starlette`, `litestar` and `blacksheep`.
142
139
 
143
140
  ## Usage
144
141
 
@@ -1,21 +1,17 @@
1
1
  <p align="center">
2
2
  <a href="https://apitally.io" target="_blank">
3
3
  <picture>
4
- <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-vertical-dark.png">
5
- <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-vertical-light.png">
6
- <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-vertical-light.png" width="150">
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-horizontal-new-dark.png">
5
+ <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-horizontal-new-light.png">
6
+ <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-horizontal-new-light.png" width="220">
7
7
  </picture>
8
8
  </a>
9
9
  </p>
10
-
11
- <p align="center"><b>Simple, privacy-focused API monitoring & analytics</b></p>
12
-
13
- <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>
10
+ <p align="center"><b>API monitoring & analytics made simple</b></p>
11
+ <p align="center" style="color: #ccc;">Real-time metrics, request logs, and alerts for your APIs — with just a few lines of code.</p>
12
+ <br>
13
+ <img alt="Apitally screenshots" src="https://assets.apitally.io/screenshots/overview.png">
14
14
  <br>
15
-
16
- ![Apitally screenshots](https://assets.apitally.io/screenshots/overview.png)
17
-
18
- ---
19
15
 
20
16
  # Apitally SDK for Python
21
17
 
@@ -72,7 +68,7 @@ pip install apitally[fastapi]
72
68
  ```
73
69
 
74
70
  The available extras are: `fastapi`, `flask`, `django_rest_framework`,
75
- `django_ninja`, `starlette` and `litestar`.
71
+ `django_ninja`, `starlette`, `litestar` and `blacksheep`.
76
72
 
77
73
  ## Usage
78
74
 
@@ -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 = ["application/json", "text/plain"]
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,8 +133,9 @@ 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.
100
- mask_request_body_callback: Callback to mask the request body. Expects (method, path, body) and returns the masked body as bytes or None.
101
- mask_response_body_callback: Callback to mask the response body. Expects (method, path, body) and returns the masked body as bytes or None.
136
+ mask_body_fields: Body fields to mask in logs. Expects regular expressions.
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.
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.
103
140
  exclude_callback: Callback to exclude requests from logging. Should expect two arguments, `request: RequestDict` and `response: ResponseDict`, and return True to exclude the request.
104
141
  """
@@ -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.write_deque: deque[Dict[str, Any]] = deque([], MAX_REQUESTS_IN_DEQUE)
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, request: RequestDict, response: ResponseDict, exception: Optional[BaseException] = None
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: Dict[str, Any] = {
241
+ item: RequestLogItem = {
234
242
  "uuid": str(uuid4()),
235
- "request": _skip_empty_values(request),
236
- "response": _skip_empty_values(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)