Skip to content

Commit 99c0339

Browse files
authored
Add support for BlackSheep (#102)
* WIP * WIP * WIP * Fix * Fix * Fix * Fixes * Fix README * Simplify test app
1 parent 4daccb7 commit 99c0339

File tree

9 files changed

+2179
-1519
lines changed

9 files changed

+2179
-1519
lines changed

.github/workflows/tests.yaml

+9
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,16 @@ jobs:
6767
- litestar==2.6.1
6868
- litestar==2.3.0
6969
- litestar==2.0.1
70+
- blacksheep
71+
- blacksheep==2.2.0
72+
- blacksheep==2.1.0
7073
exclude:
74+
- python: "3.8"
75+
deps: blacksheep
76+
- python: "3.8"
77+
deps: blacksheep==2.2.0
78+
- python: "3.8"
79+
deps: blacksheep==2.1.0
7180
- python: "3.12"
7281
deps: fastapi==0.100.1 starlette
7382
- python: "3.12"

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ This SDK for Apitally currently supports the following Python web frameworks:
3131
- [Flask](https://docs.apitally.io/frameworks/flask)
3232
- [Starlette](https://docs.apitally.io/frameworks/starlette)
3333
- [Litestar](https://docs.apitally.io/frameworks/litestar)
34+
- [BlackSheep](https://docs.apitally.io/frameworks/blacksheep)
3435

3536
Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
3637
the 📚 [documentation](https://docs.apitally.io).
@@ -172,6 +173,24 @@ app = Litestar(
172173
)
173174
```
174175

176+
### BlackSheep
177+
178+
This is an example of how to add the Apitally middleware to a BlackSheep
179+
application. For further instructions, see our
180+
[setup guide for BlackSheep](https://docs.apitally.io/frameworks/blacksheep).
181+
182+
```python
183+
from blacksheep import Application
184+
from apitally.blacksheep import use_apitally
185+
186+
app = Application()
187+
use_apitally(
188+
app,
189+
client_id="your-client-id",
190+
env="dev", # or "prod" etc.
191+
)
192+
```
193+
175194
## Getting help
176195

177196
If you need help please

apitally/blacksheep.py

+236
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import asyncio
2+
import time
3+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
4+
5+
from blacksheep import Application, Headers, Request, Response
6+
from blacksheep.server.openapi.v3 import Info, OpenAPIHandler, Operation
7+
from blacksheep.server.routing import RouteMatch
8+
9+
from apitally.client.client_asyncio import ApitallyClient
10+
from apitally.client.consumers import Consumer as ApitallyConsumer
11+
from apitally.client.request_logging import (
12+
BODY_TOO_LARGE,
13+
MAX_BODY_SIZE,
14+
RequestLogger,
15+
RequestLoggingConfig,
16+
)
17+
from apitally.common import get_versions, parse_int
18+
19+
20+
__all__ = ["use_apitally", "ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
21+
22+
23+
def use_apitally(
24+
app: Application,
25+
client_id: str,
26+
env: str = "dev",
27+
request_logging_config: Optional[RequestLoggingConfig] = None,
28+
app_version: Optional[str] = None,
29+
identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
30+
) -> None:
31+
original_get_match = app.router.get_match
32+
33+
def _wrapped_router_get_match(request: Request) -> Optional[RouteMatch]:
34+
match = original_get_match(request)
35+
if match is not None:
36+
setattr(request, "_route_pattern", match.pattern.decode())
37+
return match
38+
39+
app.router.get_match = _wrapped_router_get_match # type: ignore[assignment,method-assign]
40+
41+
middleware = ApitallyMiddleware(
42+
app,
43+
client_id,
44+
env=env,
45+
request_logging_config=request_logging_config,
46+
app_version=app_version,
47+
identify_consumer_callback=identify_consumer_callback,
48+
)
49+
app.middlewares.append(middleware)
50+
51+
52+
class ApitallyMiddleware:
53+
def __init__(
54+
self,
55+
app: Application,
56+
client_id: str,
57+
env: str = "dev",
58+
request_logging_config: Optional[RequestLoggingConfig] = None,
59+
app_version: Optional[str] = None,
60+
identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
61+
) -> None:
62+
self.app = app
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.client.start_sync_loop()
70+
self._delayed_set_startup_data_task: Optional[asyncio.Task] = None
71+
self.delayed_set_startup_data(app_version)
72+
self.app.on_stop += self.on_stop
73+
74+
self.capture_request_body = (
75+
self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
76+
)
77+
self.capture_response_body = (
78+
self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
79+
)
80+
81+
def delayed_set_startup_data(self, app_version: Optional[str] = None) -> None:
82+
self._delayed_set_startup_data_task = asyncio.create_task(self._delayed_set_startup_data(app_version))
83+
84+
async def _delayed_set_startup_data(self, app_version: Optional[str] = None) -> None:
85+
await asyncio.sleep(1.0) # Short delay to allow app routes to be registered first
86+
data = _get_startup_data(self.app, app_version=app_version)
87+
self.client.set_startup_data(data)
88+
89+
async def on_stop(self, application: Application) -> None:
90+
await self.client.handle_shutdown()
91+
92+
def get_consumer(self, request: Request) -> Optional[ApitallyConsumer]:
93+
identity = request.user or request.identity or None
94+
if identity is not None and identity.has_claim("apitally_consumer"):
95+
return ApitallyConsumer.from_string_or_object(identity.get("apitally_consumer"))
96+
if self.identify_consumer_callback is not None:
97+
consumer = self.identify_consumer_callback(request)
98+
return ApitallyConsumer.from_string_or_object(consumer)
99+
return None
100+
101+
async def __call__(self, request: Request, handler: Callable[[Request], Awaitable[Response]]) -> Response:
102+
if not self.client.enabled:
103+
return await handler(request)
104+
105+
timestamp = time.time()
106+
start_time = time.perf_counter()
107+
response: Optional[Response] = None
108+
exception: Optional[BaseException] = None
109+
110+
try:
111+
response = await handler(request)
112+
except BaseException as e:
113+
exception = e
114+
raise e from None
115+
finally:
116+
response_time = time.perf_counter() - start_time
117+
118+
consumer = self.get_consumer(request)
119+
consumer_identifier = consumer.identifier if consumer else None
120+
self.client.consumer_registry.add_or_update_consumer(consumer)
121+
122+
route_pattern: Optional[str] = getattr(request, "_route_pattern", None)
123+
request_size = parse_int(request.get_first_header(b"Content-Length"))
124+
request_content_type = (request.content_type() or b"").decode() or None
125+
request_body = b""
126+
127+
response_status = response.status if response else 500
128+
response_size: Optional[int] = None
129+
response_headers = Headers()
130+
response_body = b""
131+
132+
if self.capture_request_body and RequestLogger.is_supported_content_type(request_content_type):
133+
if request_size is not None and request_size > MAX_BODY_SIZE:
134+
request_body = BODY_TOO_LARGE
135+
else:
136+
request_body = await request.read() or b""
137+
if request_size is None:
138+
request_size = len(request_body)
139+
140+
if response is not None:
141+
response_size = (
142+
response.content.length
143+
if response.content
144+
else parse_int(response.get_first_header(b"Content-Length"))
145+
)
146+
response_content_type = (response.content_type() or b"").decode()
147+
148+
response_headers = response.headers.clone()
149+
if not response_headers.contains(b"Content-Type") and response.content:
150+
response_headers.set(b"Content-Type", response.content.type)
151+
if not response_headers.contains(b"Content-Length") and response.content:
152+
response_headers.set(b"Content-Length", str(response.content.length).encode())
153+
154+
if self.capture_response_body and RequestLogger.is_supported_content_type(response_content_type):
155+
if response_size is not None and response_size > MAX_BODY_SIZE:
156+
response_body = BODY_TOO_LARGE
157+
else:
158+
response_body = await response.read() or b""
159+
if response_size is None or response_size < 0:
160+
response_size = len(response_body)
161+
162+
if route_pattern and request.method.upper() != "OPTIONS":
163+
self.client.request_counter.add_request(
164+
consumer=consumer_identifier,
165+
method=request.method.upper(),
166+
path=route_pattern,
167+
status_code=response_status,
168+
response_time=response_time,
169+
request_size=request_size,
170+
response_size=response_size,
171+
)
172+
173+
if response_status == 500 and exception is not None:
174+
self.client.server_error_counter.add_server_error(
175+
consumer=consumer_identifier,
176+
method=request.method.upper(),
177+
path=route_pattern,
178+
exception=exception,
179+
)
180+
181+
if self.client.request_logger.enabled:
182+
self.client.request_logger.log_request(
183+
request={
184+
"timestamp": timestamp,
185+
"method": request.method.upper(),
186+
"path": route_pattern,
187+
"url": _get_full_url(request),
188+
"headers": _transform_headers(request.headers),
189+
"size": request_size,
190+
"consumer": consumer_identifier,
191+
"body": request_body,
192+
},
193+
response={
194+
"status_code": response_status,
195+
"response_time": response_time,
196+
"headers": _transform_headers(response_headers),
197+
"size": response_size,
198+
"body": response_body,
199+
},
200+
exception=exception,
201+
)
202+
203+
return response
204+
205+
206+
def _get_full_url(request: Request) -> str:
207+
return f"{request.scheme}://{request.host}/{str(request.url).lstrip('/')}"
208+
209+
210+
def _transform_headers(headers: Headers) -> List[Tuple[str, str]]:
211+
return [(key.decode(), value.decode()) for key, value in headers.items()]
212+
213+
214+
def _get_startup_data(app: Application, app_version: Optional[str] = None) -> Dict[str, Any]:
215+
return {
216+
"paths": _get_paths(app),
217+
"versions": get_versions("blacksheep", app_version=app_version),
218+
"client": "python:blacksheep",
219+
}
220+
221+
222+
def _get_paths(app: Application) -> List[Dict[str, str]]:
223+
openapi = OpenAPIHandler(info=Info(title="", version=""))
224+
paths = []
225+
methods = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
226+
for path, path_item in openapi.get_paths(app).items():
227+
for method in methods:
228+
operation: Operation = getattr(path_item, method, None)
229+
if operation is not None:
230+
item = {"method": method.upper(), "path": path}
231+
if operation.summary:
232+
item["summary"] = operation.summary
233+
if operation.description:
234+
item["description"] = operation.description
235+
paths.append(item)
236+
return paths

apitally/client/request_logging.py

+5
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,11 @@ def log_request(
225225
request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
226226
response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
227227

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+
228233
item: Dict[str, Any] = {
229234
"uuid": str(uuid4()),
230235
"request": _skip_empty_values(request),

apitally/client/requests.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,17 @@ def add_request(
4949
if request_size is not None:
5050
with contextlib.suppress(ValueError):
5151
request_size = int(request_size)
52-
request_size_kb_bin = request_size // 1000 # In KB, rounded down to nearest 1KB
53-
self.request_size_sums[request_info] += request_size
54-
self.request_sizes.setdefault(request_info, Counter())[request_size_kb_bin] += 1
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
5556
if response_size is not None:
5657
with contextlib.suppress(ValueError):
5758
response_size = int(response_size)
58-
response_size_kb_bin = response_size // 1000 # In KB, rounded down to nearest 1KB
59-
self.response_size_sums[request_info] += response_size
60-
self.response_sizes.setdefault(request_info, Counter())[response_size_kb_bin] += 1
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
6163

6264
def get_and_reset_requests(self) -> List[Dict[str, Any]]:
6365
data: List[Dict[str, Any]] = []

apitally/common.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import Any, Dict, Optional, Union
66

77

8-
def parse_int(x: Union[str, int, None]) -> Optional[int]:
8+
def parse_int(x: Union[str, bytes, int, None]) -> Optional[int]:
99
if x is None:
1010
return None
1111
try:

0 commit comments

Comments
 (0)