apitally 0.14.6__tar.gz → 0.15.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 (52) hide show
  1. {apitally-0.14.6 → apitally-0.15.0}/.pre-commit-config.yaml +2 -2
  2. {apitally-0.14.6 → apitally-0.15.0}/PKG-INFO +17 -10
  3. {apitally-0.14.6 → apitally-0.15.0}/README.md +16 -9
  4. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/request_logging.py +22 -6
  5. apitally-0.15.0/apitally/client/sentry.py +38 -0
  6. apitally-0.15.0/apitally/client/server_errors.py +100 -0
  7. {apitally-0.14.6 → apitally-0.15.0}/apitally/django.py +1 -0
  8. {apitally-0.14.6 → apitally-0.15.0}/apitally/flask.py +1 -0
  9. {apitally-0.14.6 → apitally-0.15.0}/apitally/litestar.py +1 -0
  10. {apitally-0.14.6 → apitally-0.15.0}/apitally/starlette.py +1 -0
  11. {apitally-0.14.6 → apitally-0.15.0}/tests/test_client_request_logging.py +12 -10
  12. {apitally-0.14.6 → apitally-0.15.0}/tests/test_client_server_errors.py +9 -3
  13. {apitally-0.14.6 → apitally-0.15.0}/uv.lock +150 -101
  14. apitally-0.14.6/apitally/client/server_errors.py +0 -126
  15. {apitally-0.14.6 → apitally-0.15.0}/.github/workflows/publish.yaml +0 -0
  16. {apitally-0.14.6 → apitally-0.15.0}/.github/workflows/summary.yaml +0 -0
  17. {apitally-0.14.6 → apitally-0.15.0}/.github/workflows/tests.yaml +0 -0
  18. {apitally-0.14.6 → apitally-0.15.0}/.gitignore +0 -0
  19. {apitally-0.14.6 → apitally-0.15.0}/LICENSE +0 -0
  20. {apitally-0.14.6 → apitally-0.15.0}/Makefile +0 -0
  21. {apitally-0.14.6 → apitally-0.15.0}/apitally/__init__.py +0 -0
  22. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/__init__.py +0 -0
  23. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/client_asyncio.py +0 -0
  24. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/client_base.py +0 -0
  25. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/client_threading.py +0 -0
  26. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/consumers.py +0 -0
  27. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/logging.py +0 -0
  28. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/requests.py +0 -0
  29. {apitally-0.14.6 → apitally-0.15.0}/apitally/client/validation_errors.py +0 -0
  30. {apitally-0.14.6 → apitally-0.15.0}/apitally/common.py +0 -0
  31. {apitally-0.14.6 → apitally-0.15.0}/apitally/django_ninja.py +0 -0
  32. {apitally-0.14.6 → apitally-0.15.0}/apitally/django_rest_framework.py +0 -0
  33. {apitally-0.14.6 → apitally-0.15.0}/apitally/fastapi.py +0 -0
  34. {apitally-0.14.6 → apitally-0.15.0}/apitally/py.typed +0 -0
  35. {apitally-0.14.6 → apitally-0.15.0}/pyproject.toml +0 -0
  36. {apitally-0.14.6 → apitally-0.15.0}/renovate.json +0 -0
  37. {apitally-0.14.6 → apitally-0.15.0}/tests/__init__.py +0 -0
  38. {apitally-0.14.6 → apitally-0.15.0}/tests/conftest.py +0 -0
  39. {apitally-0.14.6 → apitally-0.15.0}/tests/constants.py +0 -0
  40. {apitally-0.14.6 → apitally-0.15.0}/tests/django_ninja_urls.py +0 -0
  41. {apitally-0.14.6 → apitally-0.15.0}/tests/django_rest_framework_urls.py +0 -0
  42. {apitally-0.14.6 → apitally-0.15.0}/tests/test_client_asyncio.py +0 -0
  43. {apitally-0.14.6 → apitally-0.15.0}/tests/test_client_consumers.py +0 -0
  44. {apitally-0.14.6 → apitally-0.15.0}/tests/test_client_requests.py +0 -0
  45. {apitally-0.14.6 → apitally-0.15.0}/tests/test_client_threading.py +0 -0
  46. {apitally-0.14.6 → apitally-0.15.0}/tests/test_client_validation_errors.py +0 -0
  47. {apitally-0.14.6 → apitally-0.15.0}/tests/test_django_ninja.py +0 -0
  48. {apitally-0.14.6 → apitally-0.15.0}/tests/test_django_rest_framework.py +0 -0
  49. {apitally-0.14.6 → apitally-0.15.0}/tests/test_fastapi.py +0 -0
  50. {apitally-0.14.6 → apitally-0.15.0}/tests/test_flask.py +0 -0
  51. {apitally-0.14.6 → apitally-0.15.0}/tests/test_litestar.py +0 -0
  52. {apitally-0.14.6 → apitally-0.15.0}/tests/test_starlette.py +0 -0
@@ -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.9.9
11
+ rev: v0.9.10
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.6.3
17
+ rev: 0.6.5
18
18
  hooks:
19
19
  - id: uv-lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apitally
3
- Version: 0.14.6
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
@@ -80,14 +80,13 @@ Description-Content-Type: text/markdown
80
80
 
81
81
  ---
82
82
 
83
- # Apitally client library for Python
83
+ # Apitally SDK for Python
84
84
 
85
85
  [![Tests](https://github.com/apitally/apitally-py/actions/workflows/tests.yaml/badge.svg?event=push)](https://github.com/apitally/apitally-py/actions)
86
86
  [![Codecov](https://codecov.io/gh/apitally/apitally-py/graph/badge.svg?token=UNLYBY4Y3V)](https://codecov.io/gh/apitally/apitally-py)
87
87
  [![PyPI](https://img.shields.io/pypi/v/apitally?logo=pypi&logoColor=white&color=%23006dad)](https://pypi.org/project/apitally/)
88
88
 
89
- This client library for Apitally currently supports the following Python web
90
- frameworks:
89
+ This SDK for Apitally currently supports the following Python web frameworks:
91
90
 
92
91
  - [FastAPI](https://docs.apitally.io/frameworks/fastapi)
93
92
  - [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
@@ -103,19 +102,27 @@ the 📚 [documentation](https://docs.apitally.io).
103
102
 
104
103
  ### API analytics
105
104
 
106
- Track traffic, error and performance metrics for your API, each endpoint and individual API consumers, allowing you to make informed, data-driven engineering and product decisions.
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.
107
108
 
108
109
  ### Error tracking
109
110
 
110
- Understand which validation rules in your endpoints cause client errors. Capture error details and stack traces for 500 error responses, and have them linked to Sentry issues automatically.
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.
111
114
 
112
115
  ### Request logging
113
116
 
114
- Drill down from insights to individual requests or use powerful filtering to understand how consumers have interacted with your API. Configure exactly what is included in the logs to meet your requirements.
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.
115
120
 
116
121
  ### API monitoring & alerting
117
122
 
118
- Get notified immediately if something isn't right using custom alerts, synthetic uptime checks and heartbeat monitoring. Notifications can be delivered via email, Slack or Microsoft Teams.
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.
119
126
 
120
127
  ## Install
121
128
 
@@ -191,8 +198,8 @@ app.wsgi_app = ApitallyMiddleware(
191
198
 
192
199
  ### Starlette
193
200
 
194
- This is an example of how to add the Apitally middleware to a Starlette application.
195
- For further instructions, see our
201
+ This is an example of how to add the Apitally middleware to a Starlette
202
+ application. For further instructions, see our
196
203
  [setup guide for Starlette](https://docs.apitally.io/frameworks/starlette).
197
204
 
198
205
  ```python
@@ -17,14 +17,13 @@
17
17
 
18
18
  ---
19
19
 
20
- # Apitally client library for Python
20
+ # Apitally SDK for Python
21
21
 
22
22
  [![Tests](https://github.com/apitally/apitally-py/actions/workflows/tests.yaml/badge.svg?event=push)](https://github.com/apitally/apitally-py/actions)
23
23
  [![Codecov](https://codecov.io/gh/apitally/apitally-py/graph/badge.svg?token=UNLYBY4Y3V)](https://codecov.io/gh/apitally/apitally-py)
24
24
  [![PyPI](https://img.shields.io/pypi/v/apitally?logo=pypi&logoColor=white&color=%23006dad)](https://pypi.org/project/apitally/)
25
25
 
26
- This client library for Apitally currently supports the following Python web
27
- frameworks:
26
+ This SDK for Apitally currently supports the following Python web frameworks:
28
27
 
29
28
  - [FastAPI](https://docs.apitally.io/frameworks/fastapi)
30
29
  - [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
@@ -40,19 +39,27 @@ the 📚 [documentation](https://docs.apitally.io).
40
39
 
41
40
  ### API analytics
42
41
 
43
- Track traffic, error and performance metrics for your API, each endpoint and individual API consumers, allowing you to make informed, data-driven engineering and product decisions.
42
+ Track traffic, error and performance metrics for your API, each endpoint and
43
+ individual API consumers, allowing you to make informed, data-driven engineering
44
+ and product decisions.
44
45
 
45
46
  ### Error tracking
46
47
 
47
- Understand which validation rules in your endpoints cause client errors. Capture error details and stack traces for 500 error responses, and have them linked to Sentry issues automatically.
48
+ Understand which validation rules in your endpoints cause client errors. Capture
49
+ error details and stack traces for 500 error responses, and have them linked to
50
+ Sentry issues automatically.
48
51
 
49
52
  ### Request logging
50
53
 
51
- Drill down from insights to individual requests or use powerful filtering to understand how consumers have interacted with your API. Configure exactly what is included in the logs to meet your requirements.
54
+ Drill down from insights to individual requests or use powerful filtering to
55
+ understand how consumers have interacted with your API. Configure exactly what
56
+ is included in the logs to meet your requirements.
52
57
 
53
58
  ### API monitoring & alerting
54
59
 
55
- Get notified immediately if something isn't right using custom alerts, synthetic uptime checks and heartbeat monitoring. Notifications can be delivered via email, Slack or Microsoft Teams.
60
+ Get notified immediately if something isn't right using custom alerts, synthetic
61
+ uptime checks and heartbeat monitoring. Notifications can be delivered via
62
+ email, Slack or Microsoft Teams.
56
63
 
57
64
  ## Install
58
65
 
@@ -128,8 +135,8 @@ app.wsgi_app = ApitallyMiddleware(
128
135
 
129
136
  ### Starlette
130
137
 
131
- This is an example of how to add the Apitally middleware to a Starlette application.
132
- For further instructions, see our
138
+ This is an example of how to add the Apitally middleware to a Starlette
139
+ application. For further instructions, see our
133
140
  [setup guide for Starlette](https://docs.apitally.io/frameworks/starlette).
134
141
 
135
142
  ```python
@@ -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[bytes] = deque([], MAX_REQUESTS_IN_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(self, request: RequestDict, response: ResponseDict) -> None:
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"])
@@ -215,13 +225,19 @@ class RequestLogger:
215
225
  request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
216
226
  response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
217
227
 
218
- item = {
228
+ item: Dict[str, Any] = {
219
229
  "uuid": str(uuid4()),
220
230
  "request": _skip_empty_values(request),
221
231
  "response": _skip_empty_values(response),
222
232
  }
223
- serialized_item = self.serialize(item)
224
- self.write_deque.append(serialized_item)
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)
225
241
 
226
242
  def write_to_file(self) -> None:
227
243
  if not self.enabled or len(self.write_deque) == 0:
@@ -232,7 +248,7 @@ class RequestLogger:
232
248
  while True:
233
249
  try:
234
250
  item = self.write_deque.popleft()
235
- self.file.write_line(item)
251
+ self.file.write_line(self.serialize(item))
236
252
  except IndexError:
237
253
  break
238
254
 
@@ -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)
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ import threading
6
+ import traceback
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+ from typing import Any, Dict, List, Optional, Set
10
+
11
+ from apitally.client.sentry import get_sentry_event_id_async
12
+
13
+
14
+ MAX_EXCEPTION_MSG_LENGTH = 2048
15
+ MAX_EXCEPTION_TRACEBACK_LENGTH = 65536
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ServerError:
20
+ consumer: Optional[str]
21
+ method: str
22
+ path: str
23
+ type: str
24
+ msg: str
25
+ traceback: str
26
+
27
+
28
+ class ServerErrorCounter:
29
+ def __init__(self) -> None:
30
+ self.error_counts: Counter[ServerError] = Counter()
31
+ self.sentry_event_ids: Dict[ServerError, str] = {}
32
+ self._lock = threading.Lock()
33
+ self._tasks: Set[asyncio.Task] = set()
34
+
35
+ def add_server_error(self, consumer: Optional[str], method: str, path: str, exception: BaseException) -> None:
36
+ if not isinstance(exception, BaseException):
37
+ return # pragma: no cover
38
+ with self._lock:
39
+ server_error = ServerError(
40
+ consumer=consumer,
41
+ method=method.upper(),
42
+ path=path,
43
+ type=get_exception_type(exception),
44
+ msg=get_truncated_exception_msg(exception),
45
+ traceback=get_truncated_exception_traceback(exception),
46
+ )
47
+ self.error_counts[server_error] += 1
48
+ get_sentry_event_id_async(lambda event_id: self.sentry_event_ids.update({server_error: event_id}))
49
+
50
+ def get_and_reset_server_errors(self) -> List[Dict[str, Any]]:
51
+ data: List[Dict[str, Any]] = []
52
+ with self._lock:
53
+ for server_error, count in self.error_counts.items():
54
+ data.append(
55
+ {
56
+ "consumer": server_error.consumer,
57
+ "method": server_error.method,
58
+ "path": server_error.path,
59
+ "type": server_error.type,
60
+ "msg": server_error.msg,
61
+ "traceback": server_error.traceback,
62
+ "sentry_event_id": self.sentry_event_ids.get(server_error),
63
+ "error_count": count,
64
+ }
65
+ )
66
+ self.error_counts.clear()
67
+ self.sentry_event_ids.clear()
68
+ return data
69
+
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()
@@ -216,6 +216,7 @@ class ApitallyMiddleware:
216
216
  "size": response_size,
217
217
  "body": response_body,
218
218
  },
219
+ exception=getattr(request, "unhandled_exception", None),
219
220
  )
220
221
  else:
221
222
  response = self.get_response(request)
@@ -190,6 +190,7 @@ class ApitallyMiddleware:
190
190
  "size": response_size,
191
191
  "body": response_body,
192
192
  },
193
+ exception=g.unhandled_exception if "unhandled_exception" in g else None,
193
194
  )
194
195
 
195
196
  def get_path(self, environ: WSGIEnvironment) -> Optional[str]:
@@ -251,6 +251,7 @@ class ApitallyPlugin(InitPluginProtocol):
251
251
  "size": response_size,
252
252
  "body": response_body,
253
253
  },
254
+ exception=request.state["exception"] if "exception" in request.state else None,
254
255
  )
255
256
 
256
257
  def get_path(self, request: Request) -> Optional[str]:
@@ -228,6 +228,7 @@ class ApitallyMiddleware:
228
228
  "size": response_size,
229
229
  "body": response_body,
230
230
  },
231
+ exception=exception,
231
232
  )
232
233
 
233
234
  def get_path(self, request: Request, routes: Optional[list[BaseRoute]] = None) -> Optional[str]:
@@ -60,7 +60,7 @@ async def test_request_logger_end_to_end(
60
60
  request_logger: RequestLogger, request_dict: RequestDict, response_dict: ResponseDict
61
61
  ):
62
62
  for _ in range(3):
63
- request_logger.log_request(request_dict, response_dict)
63
+ request_logger.log_request(request_dict, response_dict, RuntimeError("test"))
64
64
 
65
65
  request_logger.write_to_file()
66
66
  assert request_logger.current_file_size > 0
@@ -98,6 +98,8 @@ async def test_request_logger_end_to_end(
98
98
  assert item["response"]["size"] == response_dict["size"]
99
99
  assert base64.b64decode(item["request"]["body"]) == request_dict["body"]
100
100
  assert base64.b64decode(item["response"]["body"]) == response_dict["body"]
101
+ assert item["exception"]["type"] == "builtins.RuntimeError"
102
+ assert item["exception"]["message"] == "test"
101
103
 
102
104
 
103
105
  def test_request_log_exclusion(request_logger: RequestLogger, request_dict: RequestDict, response_dict: ResponseDict):
@@ -111,7 +113,7 @@ def test_request_log_exclusion(request_logger: RequestLogger, request_dict: Requ
111
113
 
112
114
  request_logger.log_request(request_dict, response_dict)
113
115
  assert len(request_logger.write_deque) == 1
114
- item = json.loads(request_logger.write_deque[0])
116
+ item = request_logger.write_deque[0]
115
117
  assert item["request"]["url"] == "http://localhost:8000/test"
116
118
  assert "headers" not in item["request"]
117
119
  assert "body" not in item["request"]
@@ -160,12 +162,12 @@ def test_request_log_masking(request_logger: RequestLogger, request_dict: Reques
160
162
  request_dict["headers"] += [("Authorization", "Bearer 123456"), ("X-Test", "123456")]
161
163
  request_logger.log_request(request_dict, response_dict)
162
164
 
163
- item = json.loads(request_logger.write_deque[0])
165
+ item = request_logger.write_deque[0]
164
166
  assert item["request"]["url"] == f"http://localhost/test?secret={MASKED_QUOTED}&test={MASKED_QUOTED}&other=abcdef"
165
- assert ["Authorization", "Bearer 123456"] not in item["request"]["headers"]
166
- assert ["Authorization", MASKED] in item["request"]["headers"]
167
- assert ["X-Test", "123456"] not in item["request"]["headers"]
168
- assert ["X-Test", MASKED] in item["request"]["headers"]
169
- assert ["Accept", "text/plain"] in item["request"]["headers"]
170
- assert item["request"]["body"] == base64.b64encode(BODY_MASKED).decode()
171
- assert item["response"]["body"] == base64.b64encode(BODY_MASKED).decode()
167
+ assert ("Authorization", "Bearer 123456") not in item["request"]["headers"]
168
+ assert ("Authorization", MASKED) in item["request"]["headers"]
169
+ assert ("X-Test", "123456") not in item["request"]["headers"]
170
+ assert ("X-Test", MASKED) in item["request"]["headers"]
171
+ assert ("Accept", "text/plain") in item["request"]["headers"]
172
+ assert item["request"]["body"] == BODY_MASKED
173
+ assert item["response"]["body"] == BODY_MASKED
@@ -29,7 +29,11 @@ def test_server_error_counter():
29
29
 
30
30
 
31
31
  def test_exception_truncation(mocker: MockerFixture):
32
- from apitally.client.server_errors import ServerErrorCounter
32
+ from apitally.client.server_errors import (
33
+ get_exception_type,
34
+ get_truncated_exception_msg,
35
+ get_truncated_exception_traceback,
36
+ )
33
37
 
34
38
  mocker.patch("apitally.client.server_errors.MAX_EXCEPTION_MSG_LENGTH", 32)
35
39
  mocker.patch("apitally.client.server_errors.MAX_EXCEPTION_TRACEBACK_LENGTH", 128)
@@ -37,9 +41,11 @@ def test_exception_truncation(mocker: MockerFixture):
37
41
  try:
38
42
  raise ValueError("a" * 88)
39
43
  except ValueError as e:
40
- msg = ServerErrorCounter._get_truncated_exception_msg(e)
41
- tb = ServerErrorCounter._get_truncated_exception_traceback(e)
44
+ type_ = get_exception_type(e)
45
+ msg = get_truncated_exception_msg(e)
46
+ tb = get_truncated_exception_traceback(e)
42
47
 
48
+ assert type_ == "builtins.ValueError"
43
49
  assert len(msg) == 32
44
50
  assert msg.endswith("... (truncated)")
45
51
  assert len(tb) <= 128