apitally 0.15.1__py3-none-any.whl → 0.16.1__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/blacksheep.py +231 -0
- apitally/client/request_logging.py +5 -0
- apitally/client/requests.py +8 -6
- apitally/common.py +1 -1
- {apitally-0.15.1.dist-info → apitally-0.16.1.dist-info}/METADATA +25 -2
- {apitally-0.15.1.dist-info → apitally-0.16.1.dist-info}/RECORD +8 -7
- {apitally-0.15.1.dist-info → apitally-0.16.1.dist-info}/WHEEL +0 -0
- {apitally-0.15.1.dist-info → apitally-0.16.1.dist-info}/licenses/LICENSE +0 -0
apitally/blacksheep.py
ADDED
@@ -0,0 +1,231 @@
|
|
1
|
+
import time
|
2
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
|
3
|
+
|
4
|
+
from blacksheep import Application, Headers, Request, Response
|
5
|
+
from blacksheep.server.openapi.v3 import Info, OpenAPIHandler, Operation
|
6
|
+
from blacksheep.server.routing import RouteMatch
|
7
|
+
|
8
|
+
from apitally.client.client_asyncio import ApitallyClient
|
9
|
+
from apitally.client.consumers import Consumer as ApitallyConsumer
|
10
|
+
from apitally.client.request_logging import (
|
11
|
+
BODY_TOO_LARGE,
|
12
|
+
MAX_BODY_SIZE,
|
13
|
+
RequestLogger,
|
14
|
+
RequestLoggingConfig,
|
15
|
+
)
|
16
|
+
from apitally.common import get_versions, parse_int
|
17
|
+
|
18
|
+
|
19
|
+
__all__ = ["use_apitally", "ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
|
20
|
+
|
21
|
+
|
22
|
+
def use_apitally(
|
23
|
+
app: Application,
|
24
|
+
client_id: str,
|
25
|
+
env: str = "dev",
|
26
|
+
request_logging_config: Optional[RequestLoggingConfig] = None,
|
27
|
+
app_version: Optional[str] = None,
|
28
|
+
identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
|
29
|
+
) -> None:
|
30
|
+
original_get_match = app.router.get_match
|
31
|
+
|
32
|
+
def _wrapped_router_get_match(request: Request) -> Optional[RouteMatch]:
|
33
|
+
match = original_get_match(request)
|
34
|
+
if match is not None:
|
35
|
+
setattr(request, "_route_pattern", match.pattern.decode())
|
36
|
+
return match
|
37
|
+
|
38
|
+
app.router.get_match = _wrapped_router_get_match # type: ignore[assignment,method-assign]
|
39
|
+
|
40
|
+
middleware = ApitallyMiddleware(
|
41
|
+
app,
|
42
|
+
client_id,
|
43
|
+
env=env,
|
44
|
+
request_logging_config=request_logging_config,
|
45
|
+
app_version=app_version,
|
46
|
+
identify_consumer_callback=identify_consumer_callback,
|
47
|
+
)
|
48
|
+
app.middlewares.append(middleware)
|
49
|
+
|
50
|
+
|
51
|
+
class ApitallyMiddleware:
|
52
|
+
def __init__(
|
53
|
+
self,
|
54
|
+
app: Application,
|
55
|
+
client_id: str,
|
56
|
+
env: str = "dev",
|
57
|
+
request_logging_config: Optional[RequestLoggingConfig] = None,
|
58
|
+
app_version: Optional[str] = None,
|
59
|
+
identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
|
60
|
+
) -> None:
|
61
|
+
self.app = app
|
62
|
+
self.app_version = app_version
|
63
|
+
self.identify_consumer_callback = identify_consumer_callback
|
64
|
+
self.client = ApitallyClient(
|
65
|
+
client_id=client_id,
|
66
|
+
env=env,
|
67
|
+
request_logging_config=request_logging_config,
|
68
|
+
)
|
69
|
+
self.app.on_start += self.after_start
|
70
|
+
self.app.on_stop += self.on_stop
|
71
|
+
|
72
|
+
self.capture_request_body = (
|
73
|
+
self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
|
74
|
+
)
|
75
|
+
self.capture_response_body = (
|
76
|
+
self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
|
77
|
+
)
|
78
|
+
|
79
|
+
async def after_start(self, application: Application) -> None:
|
80
|
+
data = _get_startup_data(application, app_version=self.app_version)
|
81
|
+
self.client.set_startup_data(data)
|
82
|
+
self.client.start_sync_loop()
|
83
|
+
|
84
|
+
async def on_stop(self, application: Application) -> None:
|
85
|
+
await self.client.handle_shutdown()
|
86
|
+
|
87
|
+
def get_consumer(self, request: Request) -> Optional[ApitallyConsumer]:
|
88
|
+
identity = request.user or request.identity or None
|
89
|
+
if identity is not None and identity.has_claim("apitally_consumer"):
|
90
|
+
return ApitallyConsumer.from_string_or_object(identity.get("apitally_consumer"))
|
91
|
+
if self.identify_consumer_callback is not None:
|
92
|
+
consumer = self.identify_consumer_callback(request)
|
93
|
+
return ApitallyConsumer.from_string_or_object(consumer)
|
94
|
+
return None
|
95
|
+
|
96
|
+
async def __call__(self, request: Request, handler: Callable[[Request], Awaitable[Response]]) -> Response:
|
97
|
+
if not self.client.enabled:
|
98
|
+
return await handler(request)
|
99
|
+
|
100
|
+
timestamp = time.time()
|
101
|
+
start_time = time.perf_counter()
|
102
|
+
response: Optional[Response] = None
|
103
|
+
exception: Optional[BaseException] = None
|
104
|
+
|
105
|
+
try:
|
106
|
+
response = await handler(request)
|
107
|
+
except BaseException as e:
|
108
|
+
exception = e
|
109
|
+
raise e from None
|
110
|
+
finally:
|
111
|
+
response_time = time.perf_counter() - start_time
|
112
|
+
|
113
|
+
consumer = self.get_consumer(request)
|
114
|
+
consumer_identifier = consumer.identifier if consumer else None
|
115
|
+
self.client.consumer_registry.add_or_update_consumer(consumer)
|
116
|
+
|
117
|
+
route_pattern: Optional[str] = getattr(request, "_route_pattern", None)
|
118
|
+
request_size = parse_int(request.get_first_header(b"Content-Length"))
|
119
|
+
request_content_type = (request.content_type() or b"").decode() or None
|
120
|
+
request_body = b""
|
121
|
+
|
122
|
+
response_status = response.status if response else 500
|
123
|
+
response_size: Optional[int] = None
|
124
|
+
response_headers = Headers()
|
125
|
+
response_body = b""
|
126
|
+
|
127
|
+
if self.capture_request_body and RequestLogger.is_supported_content_type(request_content_type):
|
128
|
+
if request_size is not None and request_size > MAX_BODY_SIZE:
|
129
|
+
request_body = BODY_TOO_LARGE
|
130
|
+
else:
|
131
|
+
request_body = await request.read() or b""
|
132
|
+
if request_size is None:
|
133
|
+
request_size = len(request_body)
|
134
|
+
|
135
|
+
if response is not None:
|
136
|
+
response_size = (
|
137
|
+
response.content.length
|
138
|
+
if response.content
|
139
|
+
else parse_int(response.get_first_header(b"Content-Length"))
|
140
|
+
)
|
141
|
+
response_content_type = (response.content_type() or b"").decode()
|
142
|
+
|
143
|
+
response_headers = response.headers.clone()
|
144
|
+
if not response_headers.contains(b"Content-Type") and response.content:
|
145
|
+
response_headers.set(b"Content-Type", response.content.type)
|
146
|
+
if not response_headers.contains(b"Content-Length") and response.content:
|
147
|
+
response_headers.set(b"Content-Length", str(response.content.length).encode())
|
148
|
+
|
149
|
+
if self.capture_response_body and RequestLogger.is_supported_content_type(response_content_type):
|
150
|
+
if response_size is not None and response_size > MAX_BODY_SIZE:
|
151
|
+
response_body = BODY_TOO_LARGE
|
152
|
+
else:
|
153
|
+
response_body = await response.read() or b""
|
154
|
+
if response_size is None or response_size < 0:
|
155
|
+
response_size = len(response_body)
|
156
|
+
|
157
|
+
if route_pattern and request.method.upper() != "OPTIONS":
|
158
|
+
self.client.request_counter.add_request(
|
159
|
+
consumer=consumer_identifier,
|
160
|
+
method=request.method.upper(),
|
161
|
+
path=route_pattern,
|
162
|
+
status_code=response_status,
|
163
|
+
response_time=response_time,
|
164
|
+
request_size=request_size,
|
165
|
+
response_size=response_size,
|
166
|
+
)
|
167
|
+
|
168
|
+
if response_status == 500 and exception is not None:
|
169
|
+
self.client.server_error_counter.add_server_error(
|
170
|
+
consumer=consumer_identifier,
|
171
|
+
method=request.method.upper(),
|
172
|
+
path=route_pattern,
|
173
|
+
exception=exception,
|
174
|
+
)
|
175
|
+
|
176
|
+
if self.client.request_logger.enabled:
|
177
|
+
self.client.request_logger.log_request(
|
178
|
+
request={
|
179
|
+
"timestamp": timestamp,
|
180
|
+
"method": request.method.upper(),
|
181
|
+
"path": route_pattern,
|
182
|
+
"url": _get_full_url(request),
|
183
|
+
"headers": _transform_headers(request.headers),
|
184
|
+
"size": request_size,
|
185
|
+
"consumer": consumer_identifier,
|
186
|
+
"body": request_body,
|
187
|
+
},
|
188
|
+
response={
|
189
|
+
"status_code": response_status,
|
190
|
+
"response_time": response_time,
|
191
|
+
"headers": _transform_headers(response_headers),
|
192
|
+
"size": response_size,
|
193
|
+
"body": response_body,
|
194
|
+
},
|
195
|
+
exception=exception,
|
196
|
+
)
|
197
|
+
|
198
|
+
return response
|
199
|
+
|
200
|
+
|
201
|
+
def _get_full_url(request: Request) -> str:
|
202
|
+
return f"{request.scheme}://{request.host}/{str(request.url).lstrip('/')}"
|
203
|
+
|
204
|
+
|
205
|
+
def _transform_headers(headers: Headers) -> List[Tuple[str, str]]:
|
206
|
+
return [(key.decode(), value.decode()) for key, value in headers.items()]
|
207
|
+
|
208
|
+
|
209
|
+
def _get_startup_data(app: Application, app_version: Optional[str] = None) -> Dict[str, Any]:
|
210
|
+
return {
|
211
|
+
"paths": _get_paths(app),
|
212
|
+
"versions": get_versions("blacksheep", app_version=app_version),
|
213
|
+
"client": "python:blacksheep",
|
214
|
+
}
|
215
|
+
|
216
|
+
|
217
|
+
def _get_paths(app: Application) -> List[Dict[str, str]]:
|
218
|
+
openapi = OpenAPIHandler(info=Info(title="", version=""))
|
219
|
+
paths = []
|
220
|
+
methods = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
221
|
+
for path, path_item in openapi.get_paths(app).items():
|
222
|
+
for method in methods:
|
223
|
+
operation: Operation = getattr(path_item, method, None)
|
224
|
+
if operation is not None:
|
225
|
+
item = {"method": method.upper(), "path": path}
|
226
|
+
if operation.summary:
|
227
|
+
item["summary"] = operation.summary
|
228
|
+
if operation.description:
|
229
|
+
item["description"] = operation.description
|
230
|
+
paths.append(item)
|
231
|
+
return paths
|
@@ -225,6 +225,11 @@ class RequestLogger:
|
|
225
225
|
request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
|
226
226
|
response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
|
227
227
|
|
228
|
+
if request["size"] is not None and request["size"] < 0:
|
229
|
+
request["size"] = None
|
230
|
+
if response["size"] is not None and response["size"] < 0:
|
231
|
+
response["size"] = None
|
232
|
+
|
228
233
|
item: Dict[str, Any] = {
|
229
234
|
"uuid": str(uuid4()),
|
230
235
|
"request": _skip_empty_values(request),
|
apitally/client/requests.py
CHANGED
@@ -49,15 +49,17 @@ class RequestCounter:
|
|
49
49
|
if request_size is not None:
|
50
50
|
with contextlib.suppress(ValueError):
|
51
51
|
request_size = int(request_size)
|
52
|
-
|
53
|
-
|
54
|
-
|
52
|
+
if request_size >= 0:
|
53
|
+
request_size_kb_bin = request_size // 1000 # In KB, rounded down to nearest 1KB
|
54
|
+
self.request_size_sums[request_info] += request_size
|
55
|
+
self.request_sizes.setdefault(request_info, Counter())[request_size_kb_bin] += 1
|
55
56
|
if response_size is not None:
|
56
57
|
with contextlib.suppress(ValueError):
|
57
58
|
response_size = int(response_size)
|
58
|
-
|
59
|
-
|
60
|
-
|
59
|
+
if response_size >= 0:
|
60
|
+
response_size_kb_bin = response_size // 1000 # In KB, rounded down to nearest 1KB
|
61
|
+
self.response_size_sums[request_info] += response_size
|
62
|
+
self.response_sizes.setdefault(request_info, Counter())[response_size_kb_bin] += 1
|
61
63
|
|
62
64
|
def get_and_reset_requests(self) -> List[Dict[str, Any]]:
|
63
65
|
data: List[Dict[str, Any]] = []
|
apitally/common.py
CHANGED
@@ -5,7 +5,7 @@ from importlib.metadata import PackageNotFoundError, version
|
|
5
5
|
from typing import Any, Dict, Optional, Union
|
6
6
|
|
7
7
|
|
8
|
-
def parse_int(x: Union[str, int, None]) -> Optional[int]:
|
8
|
+
def parse_int(x: Union[str, bytes, int, None]) -> Optional[int]:
|
9
9
|
if x is None:
|
10
10
|
return None
|
11
11
|
try:
|
@@ -1,7 +1,7 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: apitally
|
3
|
-
Version: 0.
|
4
|
-
Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette and
|
3
|
+
Version: 0.16.1
|
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
|
7
7
|
Project-URL: Repository, https://github.com/apitally/apitally-py
|
@@ -24,6 +24,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
24
24
|
Classifier: Programming Language :: Python :: 3.10
|
25
25
|
Classifier: Programming Language :: Python :: 3.11
|
26
26
|
Classifier: Programming Language :: Python :: 3.12
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
27
28
|
Classifier: Topic :: Internet
|
28
29
|
Classifier: Topic :: Internet :: WWW/HTTP
|
29
30
|
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
|
@@ -32,6 +33,9 @@ Classifier: Topic :: System :: Monitoring
|
|
32
33
|
Classifier: Typing :: Typed
|
33
34
|
Requires-Python: <4.0,>=3.8
|
34
35
|
Requires-Dist: backoff>=2.0.0
|
36
|
+
Provides-Extra: blacksheep
|
37
|
+
Requires-Dist: blacksheep>=2; extra == 'blacksheep'
|
38
|
+
Requires-Dist: httpx>=0.22.0; extra == 'blacksheep'
|
35
39
|
Provides-Extra: django-ninja
|
36
40
|
Requires-Dist: django-ninja>=0.18.0; extra == 'django-ninja'
|
37
41
|
Requires-Dist: django<5,>=2.2; (python_version < '3.10') and extra == 'django-ninja'
|
@@ -94,6 +98,7 @@ This SDK for Apitally currently supports the following Python web frameworks:
|
|
94
98
|
- [Flask](https://docs.apitally.io/frameworks/flask)
|
95
99
|
- [Starlette](https://docs.apitally.io/frameworks/starlette)
|
96
100
|
- [Litestar](https://docs.apitally.io/frameworks/litestar)
|
101
|
+
- [BlackSheep](https://docs.apitally.io/frameworks/blacksheep)
|
97
102
|
|
98
103
|
Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
|
99
104
|
the 📚 [documentation](https://docs.apitally.io).
|
@@ -235,6 +240,24 @@ app = Litestar(
|
|
235
240
|
)
|
236
241
|
```
|
237
242
|
|
243
|
+
### BlackSheep
|
244
|
+
|
245
|
+
This is an example of how to add the Apitally middleware to a BlackSheep
|
246
|
+
application. For further instructions, see our
|
247
|
+
[setup guide for BlackSheep](https://docs.apitally.io/frameworks/blacksheep).
|
248
|
+
|
249
|
+
```python
|
250
|
+
from blacksheep import Application
|
251
|
+
from apitally.blacksheep import use_apitally
|
252
|
+
|
253
|
+
app = Application()
|
254
|
+
use_apitally(
|
255
|
+
app,
|
256
|
+
client_id="your-client-id",
|
257
|
+
env="dev", # or "prod" etc.
|
258
|
+
)
|
259
|
+
```
|
260
|
+
|
238
261
|
## Getting help
|
239
262
|
|
240
263
|
If you need help please
|
@@ -1,5 +1,6 @@
|
|
1
1
|
apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
|
2
|
-
apitally/
|
2
|
+
apitally/blacksheep.py,sha256=KvcPFeiwQgWZmRglbm8SLaN6_WRs5kZ3SymB1IuLR-A,9616
|
3
|
+
apitally/common.py,sha256=azDxepViH0QW0MuufTHxeSQyLGzCkocAX_KPziWTx8A,1605
|
3
4
|
apitally/django.py,sha256=zwe8svC8rfo7TyHfOlkYTeXptxPFoRjvt0bbYvgtJKM,16892
|
4
5
|
apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
5
6
|
apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
@@ -14,12 +15,12 @@ apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbg
|
|
14
15
|
apitally/client/client_threading.py,sha256=7JPu2Uulev7X2RiSLx4HJYfvAP6Z5zB_yuSevMfQC7I,7389
|
15
16
|
apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
|
16
17
|
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
17
|
-
apitally/client/request_logging.py,sha256=
|
18
|
-
apitally/client/requests.py,sha256=
|
18
|
+
apitally/client/request_logging.py,sha256=SMvQd3WDolnb8u9rHVh2_OgXwFjL2jLZt-GpZNQ1XGk,14115
|
19
|
+
apitally/client/requests.py,sha256=SDptGOg9XvaEKFj2o3oxJz-JAuZzUrqpHnbOQixf99o,3794
|
19
20
|
apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
|
20
21
|
apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
|
21
22
|
apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
|
22
|
-
apitally-0.
|
23
|
-
apitally-0.
|
24
|
-
apitally-0.
|
25
|
-
apitally-0.
|
23
|
+
apitally-0.16.1.dist-info/METADATA,sha256=eS5sdPc9rWZRyr8WS_ZcvjSxtmiByflQ8n1GI01_0VI,9321
|
24
|
+
apitally-0.16.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
25
|
+
apitally-0.16.1.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
26
|
+
apitally-0.16.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|