apitally 0.14.5__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.5 → apitally-0.15.0}/.pre-commit-config.yaml +2 -2
  2. {apitally-0.14.5 → apitally-0.15.0}/PKG-INFO +71 -34
  3. {apitally-0.14.5 → apitally-0.15.0}/README.md +70 -33
  4. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/client_asyncio.py +2 -0
  5. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/client_base.py +1 -0
  6. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/client_threading.py +1 -0
  7. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/request_logging.py +32 -11
  8. apitally-0.15.0/apitally/client/sentry.py +38 -0
  9. apitally-0.15.0/apitally/client/server_errors.py +100 -0
  10. {apitally-0.14.5 → apitally-0.15.0}/apitally/django.py +9 -2
  11. {apitally-0.14.5 → apitally-0.15.0}/apitally/flask.py +7 -1
  12. {apitally-0.14.5 → apitally-0.15.0}/apitally/litestar.py +11 -2
  13. {apitally-0.14.5 → apitally-0.15.0}/apitally/starlette.py +11 -2
  14. {apitally-0.14.5 → apitally-0.15.0}/pyproject.toml +3 -2
  15. {apitally-0.14.5 → apitally-0.15.0}/tests/test_client_request_logging.py +12 -10
  16. {apitally-0.14.5 → apitally-0.15.0}/tests/test_client_server_errors.py +9 -3
  17. {apitally-0.14.5 → apitally-0.15.0}/tests/test_flask.py +3 -3
  18. {apitally-0.14.5 → apitally-0.15.0}/tests/test_litestar.py +6 -0
  19. {apitally-0.14.5 → apitally-0.15.0}/uv.lock +655 -355
  20. apitally-0.14.5/apitally/client/server_errors.py +0 -126
  21. {apitally-0.14.5 → apitally-0.15.0}/.github/workflows/publish.yaml +0 -0
  22. {apitally-0.14.5 → apitally-0.15.0}/.github/workflows/summary.yaml +0 -0
  23. {apitally-0.14.5 → apitally-0.15.0}/.github/workflows/tests.yaml +0 -0
  24. {apitally-0.14.5 → apitally-0.15.0}/.gitignore +0 -0
  25. {apitally-0.14.5 → apitally-0.15.0}/LICENSE +0 -0
  26. {apitally-0.14.5 → apitally-0.15.0}/Makefile +0 -0
  27. {apitally-0.14.5 → apitally-0.15.0}/apitally/__init__.py +0 -0
  28. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/__init__.py +0 -0
  29. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/consumers.py +0 -0
  30. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/logging.py +0 -0
  31. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/requests.py +0 -0
  32. {apitally-0.14.5 → apitally-0.15.0}/apitally/client/validation_errors.py +0 -0
  33. {apitally-0.14.5 → apitally-0.15.0}/apitally/common.py +0 -0
  34. {apitally-0.14.5 → apitally-0.15.0}/apitally/django_ninja.py +0 -0
  35. {apitally-0.14.5 → apitally-0.15.0}/apitally/django_rest_framework.py +0 -0
  36. {apitally-0.14.5 → apitally-0.15.0}/apitally/fastapi.py +0 -0
  37. {apitally-0.14.5 → apitally-0.15.0}/apitally/py.typed +0 -0
  38. {apitally-0.14.5 → apitally-0.15.0}/renovate.json +0 -0
  39. {apitally-0.14.5 → apitally-0.15.0}/tests/__init__.py +0 -0
  40. {apitally-0.14.5 → apitally-0.15.0}/tests/conftest.py +0 -0
  41. {apitally-0.14.5 → apitally-0.15.0}/tests/constants.py +0 -0
  42. {apitally-0.14.5 → apitally-0.15.0}/tests/django_ninja_urls.py +0 -0
  43. {apitally-0.14.5 → apitally-0.15.0}/tests/django_rest_framework_urls.py +0 -0
  44. {apitally-0.14.5 → apitally-0.15.0}/tests/test_client_asyncio.py +0 -0
  45. {apitally-0.14.5 → apitally-0.15.0}/tests/test_client_consumers.py +0 -0
  46. {apitally-0.14.5 → apitally-0.15.0}/tests/test_client_requests.py +0 -0
  47. {apitally-0.14.5 → apitally-0.15.0}/tests/test_client_threading.py +0 -0
  48. {apitally-0.14.5 → apitally-0.15.0}/tests/test_client_validation_errors.py +0 -0
  49. {apitally-0.14.5 → apitally-0.15.0}/tests/test_django_ninja.py +0 -0
  50. {apitally-0.14.5 → apitally-0.15.0}/tests/test_django_rest_framework.py +0 -0
  51. {apitally-0.14.5 → apitally-0.15.0}/tests/test_fastapi.py +0 -0
  52. {apitally-0.14.5 → 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.2
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.5.21
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.5
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
- <picture>
66
- <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-vertical-dark.png">
67
- <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-vertical-light.png">
68
- <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-vertical-light.png" width="150">
69
- </picture>
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>Analytics, logging & monitoring for REST APIs.</b></p>
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>It's super easy to use and designed to protect your data privacy.</i></p>
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
  ![Apitally screenshots](https://assets.apitally.io/screenshots/overview.png)
79
80
 
80
81
  ---
81
82
 
82
- # Apitally client library for Python
83
+ # Apitally SDK for Python
83
84
 
84
85
  [![Tests](https://github.com/apitally/apitally-py/actions/workflows/tests.yaml/badge.svg?event=push)](https://github.com/apitally/apitally-py/actions)
85
86
  [![Codecov](https://codecov.io/gh/apitally/apitally-py/graph/badge.svg?token=UNLYBY4Y3V)](https://codecov.io/gh/apitally/apitally-py)
86
87
  [![PyPI](https://img.shields.io/pypi/v/apitally?logo=pypi&logoColor=white&color=%23006dad)](https://pypi.org/project/apitally/)
87
88
 
88
- This client library for Apitally currently supports the following Python web
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
- - Middleware for different frameworks to capture metadata about API endpoints,
104
- requests and responses
105
- - Non-blocking clients that aggregate and send captured data to Apitally in
106
- regular intervals
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
- ### Django
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
- In your Django `settings.py` file:
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
- MIDDLEWARE = [
171
- "apitally.django.ApitallyMiddleware",
172
- # Other middleware ...
173
- ]
174
- APITALLY_MIDDLEWARE = {
175
- "client_id": "your-client-id",
176
- "env": "dev", # or "prod" etc.
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
@@ -1,35 +1,35 @@
1
1
  <p align="center">
2
- <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-vertical-dark.png">
4
- <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-vertical-light.png">
5
- <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-vertical-light.png" width="150">
6
- </picture>
2
+ <a href="https://apitally.io" target="_blank">
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">
7
+ </picture>
8
+ </a>
7
9
  </p>
8
10
 
9
- <p align="center"><b>Analytics, logging & monitoring for REST APIs.</b></p>
11
+ <p align="center"><b>Simple, privacy-focused API monitoring & analytics</b></p>
10
12
 
11
- <p align="center"><i>Apitally helps you understand how your APIs are being used and alerts you when things go wrong.<br>It's super easy to use and designed to protect your data privacy.</i></p>
12
-
13
- <p align="center">🔗 <b><a href="https://apitally.io" target="_blank">apitally.io</a></b></p>
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>
14
+ <br>
14
15
 
15
16
  ![Apitally screenshots](https://assets.apitally.io/screenshots/overview.png)
16
17
 
17
18
  ---
18
19
 
19
- # Apitally client library for Python
20
+ # Apitally SDK for Python
20
21
 
21
22
  [![Tests](https://github.com/apitally/apitally-py/actions/workflows/tests.yaml/badge.svg?event=push)](https://github.com/apitally/apitally-py/actions)
22
23
  [![Codecov](https://codecov.io/gh/apitally/apitally-py/graph/badge.svg?token=UNLYBY4Y3V)](https://codecov.io/gh/apitally/apitally-py)
23
24
  [![PyPI](https://img.shields.io/pypi/v/apitally?logo=pypi&logoColor=white&color=%23006dad)](https://pypi.org/project/apitally/)
24
25
 
25
- This client library for Apitally currently supports the following Python web
26
- frameworks:
26
+ This SDK for Apitally currently supports the following Python web frameworks:
27
27
 
28
28
  - [FastAPI](https://docs.apitally.io/frameworks/fastapi)
29
- - [Starlette](https://docs.apitally.io/frameworks/starlette)
30
- - [Flask](https://docs.apitally.io/frameworks/flask)
31
- - [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
32
29
  - [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
30
+ - [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
31
+ - [Flask](https://docs.apitally.io/frameworks/flask)
32
+ - [Starlette](https://docs.apitally.io/frameworks/starlette)
33
33
  - [Litestar](https://docs.apitally.io/frameworks/litestar)
34
34
 
35
35
  Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
@@ -37,10 +37,29 @@ the 📚 [documentation](https://docs.apitally.io).
37
37
 
38
38
  ## Key features
39
39
 
40
- - Middleware for different frameworks to capture metadata about API endpoints,
41
- requests and responses
42
- - Non-blocking clients that aggregate and send captured data to Apitally in
43
- regular intervals
40
+ ### API analytics
41
+
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.
45
+
46
+ ### Error tracking
47
+
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.
51
+
52
+ ### Request logging
53
+
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.
57
+
58
+ ### API monitoring & alerting
59
+
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.
44
63
 
45
64
  ## Install
46
65
 
@@ -77,6 +96,25 @@ app.add_middleware(
77
96
  )
78
97
  ```
79
98
 
99
+ ### Django
100
+
101
+ This is an example of how to add the Apitally middleware to a Django Ninja or
102
+ Django REST Framework application. For further instructions, see our
103
+ [setup guide for Django](https://docs.apitally.io/frameworks/django).
104
+
105
+ In your Django `settings.py` file:
106
+
107
+ ```python
108
+ MIDDLEWARE = [
109
+ "apitally.django.ApitallyMiddleware",
110
+ # Other middleware ...
111
+ ]
112
+ APITALLY_MIDDLEWARE = {
113
+ "client_id": "your-client-id",
114
+ "env": "dev", # or "prod" etc.
115
+ }
116
+ ```
117
+
80
118
  ### Flask
81
119
 
82
120
  This is an example of how to add the Apitally middleware to a Flask application.
@@ -95,23 +133,22 @@ app.wsgi_app = ApitallyMiddleware(
95
133
  )
96
134
  ```
97
135
 
98
- ### Django
99
-
100
- This is an example of how to add the Apitally middleware to a Django Ninja or
101
- Django REST Framework application. For further instructions, see our
102
- [setup guide for Django](https://docs.apitally.io/frameworks/django).
136
+ ### Starlette
103
137
 
104
- In your Django `settings.py` file:
138
+ This is an example of how to add the Apitally middleware to a Starlette
139
+ application. For further instructions, see our
140
+ [setup guide for Starlette](https://docs.apitally.io/frameworks/starlette).
105
141
 
106
142
  ```python
107
- MIDDLEWARE = [
108
- "apitally.django.ApitallyMiddleware",
109
- # Other middleware ...
110
- ]
111
- APITALLY_MIDDLEWARE = {
112
- "client_id": "your-client-id",
113
- "env": "dev", # or "prod" etc.
114
- }
143
+ from starlette.applications import Starlette
144
+ from apitally.starlette import ApitallyMiddleware
145
+
146
+ app = Starlette(routes=[...])
147
+ app.add_middleware(
148
+ ApitallyMiddleware,
149
+ client_id="your-client-id",
150
+ env="dev", # or "prod" etc.
151
+ )
115
152
  ```
116
153
 
117
154
  ### Litestar
@@ -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:
@@ -52,6 +52,7 @@ class ApitallyClientBase(ABC):
52
52
 
53
53
  self.client_id = client_id
54
54
  self.env = env
55
+ self.enabled = True
55
56
  self.instance_uuid = str(uuid4())
56
57
  self.request_counter = RequestCounter()
57
58
  self.validation_error_counter = ValidationErrorCounter()
@@ -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[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"])
@@ -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
- item = {
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
- serialized_item = self.serialize(item)
223
- 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)
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 content_type is not None and any(content_type.startswith(t) for t in ALLOWED_CONTENT_TYPES)
337
+ return RequestLogger.is_supported_content_type(content_type)
317
338
 
318
339
  @staticmethod
319
- def _get_user_agent(headers: List[Tuple[str, str]]) -> Optional[str]:
320
- return next((v for k, v in headers if k.lower() == "user-agent"), None)
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)
@@ -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()
@@ -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
- if self.capture_response_body and not response.streaming:
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)