apitally 0.13.0a1__py3-none-any.whl → 0.14.0rc1__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/client/{asyncio.py → client_asyncio.py} +55 -16
- apitally/client/client_base.py +97 -0
- apitally/client/{threading.py → client_threading.py} +51 -10
- apitally/client/consumers.py +66 -0
- apitally/client/request_logging.py +344 -0
- apitally/client/requests.py +86 -0
- apitally/client/server_errors.py +126 -0
- apitally/client/validation_errors.py +58 -0
- apitally/common.py +10 -1
- apitally/django.py +112 -46
- apitally/django_ninja.py +2 -2
- apitally/django_rest_framework.py +2 -2
- apitally/fastapi.py +2 -2
- apitally/flask.py +100 -26
- apitally/litestar.py +122 -54
- apitally/starlette.py +90 -29
- {apitally-0.13.0a1.dist-info → apitally-0.14.0rc1.dist-info}/METADATA +8 -9
- apitally-0.14.0rc1.dist-info/RECORD +24 -0
- {apitally-0.13.0a1.dist-info → apitally-0.14.0rc1.dist-info}/WHEEL +1 -1
- apitally/client/base.py +0 -404
- apitally-0.13.0a1.dist-info/RECORD +0 -19
- {apitally-0.13.0a1.dist-info → apitally-0.14.0rc1.dist-info}/licenses/LICENSE +0 -0
apitally/django.py
CHANGED
@@ -13,10 +13,15 @@ from django.conf import settings
|
|
13
13
|
from django.urls import URLPattern, URLResolver, get_resolver
|
14
14
|
from django.utils.module_loading import import_string
|
15
15
|
|
16
|
-
from apitally.client.
|
16
|
+
from apitally.client.client_threading import ApitallyClient
|
17
|
+
from apitally.client.consumers import Consumer as ApitallyConsumer
|
17
18
|
from apitally.client.logging import get_logger
|
18
|
-
from apitally.client.
|
19
|
-
|
19
|
+
from apitally.client.request_logging import (
|
20
|
+
BODY_TOO_LARGE,
|
21
|
+
MAX_BODY_SIZE,
|
22
|
+
RequestLoggingConfig,
|
23
|
+
)
|
24
|
+
from apitally.common import get_versions, parse_int
|
20
25
|
|
21
26
|
|
22
27
|
if TYPE_CHECKING:
|
@@ -24,7 +29,7 @@ if TYPE_CHECKING:
|
|
24
29
|
from ninja import NinjaAPI
|
25
30
|
|
26
31
|
|
27
|
-
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
32
|
+
__all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
|
28
33
|
logger = get_logger(__name__)
|
29
34
|
|
30
35
|
|
@@ -32,6 +37,7 @@ logger = get_logger(__name__)
|
|
32
37
|
class ApitallyMiddlewareConfig:
|
33
38
|
client_id: str
|
34
39
|
env: str
|
40
|
+
request_logging_config: Optional[RequestLoggingConfig]
|
35
41
|
app_version: Optional[str]
|
36
42
|
identify_consumer_callback: Optional[Callable[[HttpRequest], Union[str, ApitallyConsumer, None]]]
|
37
43
|
urlconfs: List[Optional[str]]
|
@@ -61,7 +67,11 @@ class ApitallyMiddleware:
|
|
61
67
|
if self.ninja_available and None not in self.config.urlconfs:
|
62
68
|
self.callbacks.update(_get_ninja_callbacks(self.config.urlconfs))
|
63
69
|
|
64
|
-
self.client = ApitallyClient(
|
70
|
+
self.client = ApitallyClient(
|
71
|
+
client_id=self.config.client_id,
|
72
|
+
env=self.config.env,
|
73
|
+
request_logging_config=self.config.request_logging_config,
|
74
|
+
)
|
65
75
|
self.client.start_sync_loop()
|
66
76
|
self.client.set_startup_data(
|
67
77
|
_get_startup_data(
|
@@ -70,11 +80,19 @@ class ApitallyMiddleware:
|
|
70
80
|
)
|
71
81
|
)
|
72
82
|
|
83
|
+
self.capture_request_body = (
|
84
|
+
self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
|
85
|
+
)
|
86
|
+
self.capture_response_body = (
|
87
|
+
self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
|
88
|
+
)
|
89
|
+
|
73
90
|
@classmethod
|
74
91
|
def configure(
|
75
92
|
cls,
|
76
93
|
client_id: str,
|
77
94
|
env: str = "dev",
|
95
|
+
request_logging_config: Optional[RequestLoggingConfig] = None,
|
78
96
|
app_version: Optional[str] = None,
|
79
97
|
identify_consumer_callback: Optional[str] = None,
|
80
98
|
urlconf: Optional[Union[List[Optional[str]], str]] = None,
|
@@ -82,6 +100,7 @@ class ApitallyMiddleware:
|
|
82
100
|
cls.config = ApitallyMiddlewareConfig(
|
83
101
|
client_id=client_id,
|
84
102
|
env=env,
|
103
|
+
request_logging_config=request_logging_config,
|
85
104
|
app_version=app_version,
|
86
105
|
identify_consumer_callback=import_string(identify_consumer_callback)
|
87
106
|
if identify_consumer_callback
|
@@ -90,11 +109,31 @@ class ApitallyMiddleware:
|
|
90
109
|
)
|
91
110
|
|
92
111
|
def __call__(self, request: HttpRequest) -> HttpResponse:
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
112
|
+
if request.method is not None and request.method != "OPTIONS":
|
113
|
+
timestamp = time.time()
|
114
|
+
request_size = parse_int(request.headers.get("Content-Length"))
|
115
|
+
request_body = b""
|
116
|
+
if self.capture_request_body:
|
117
|
+
request_body = (
|
118
|
+
request.body
|
119
|
+
if request_size is not None and request_size <= MAX_BODY_SIZE and len(request.body) <= MAX_BODY_SIZE
|
120
|
+
else BODY_TOO_LARGE
|
121
|
+
)
|
122
|
+
|
123
|
+
start_time = time.perf_counter()
|
124
|
+
response = self.get_response(request)
|
125
|
+
response_time = time.perf_counter() - start_time
|
126
|
+
response_size = (
|
127
|
+
parse_int(response["Content-Length"])
|
128
|
+
if response.has_header("Content-Length")
|
129
|
+
else (len(response.content) if not response.streaming else None)
|
130
|
+
)
|
131
|
+
response_body = b""
|
132
|
+
if self.capture_response_body and not response.streaming:
|
133
|
+
response_body = (
|
134
|
+
response.content if response_size is not None and response_size <= MAX_BODY_SIZE else BODY_TOO_LARGE
|
135
|
+
)
|
136
|
+
|
98
137
|
try:
|
99
138
|
consumer = self.get_consumer(request)
|
100
139
|
consumer_identifier = consumer.identifier if consumer else None
|
@@ -102,48 +141,75 @@ class ApitallyMiddleware:
|
|
102
141
|
except Exception: # pragma: no cover
|
103
142
|
logger.exception("Failed to get consumer for request")
|
104
143
|
consumer_identifier = None
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
method=request.method,
|
109
|
-
path=path,
|
110
|
-
status_code=response.status_code,
|
111
|
-
response_time=response_time,
|
112
|
-
request_size=request.headers.get("Content-Length"),
|
113
|
-
response_size=response["Content-Length"]
|
114
|
-
if response.has_header("Content-Length")
|
115
|
-
else (len(response.content) if not response.streaming else None),
|
116
|
-
)
|
117
|
-
except Exception: # pragma: no cover
|
118
|
-
logger.exception("Failed to log request metadata")
|
119
|
-
if (
|
120
|
-
response.status_code == 422
|
121
|
-
and (content_type := response.get("Content-Type")) is not None
|
122
|
-
and content_type.startswith("application/json")
|
123
|
-
):
|
124
|
-
try:
|
125
|
-
with contextlib.suppress(json.JSONDecodeError):
|
126
|
-
body = json.loads(response.content)
|
127
|
-
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
128
|
-
# Log Django Ninja / Pydantic validation errors
|
129
|
-
self.client.validation_error_counter.add_validation_errors(
|
130
|
-
consumer=consumer_identifier,
|
131
|
-
method=request.method,
|
132
|
-
path=path,
|
133
|
-
detail=body["detail"],
|
134
|
-
)
|
135
|
-
except Exception: # pragma: no cover
|
136
|
-
logger.exception("Failed to log validation errors")
|
137
|
-
if response.status_code == 500 and hasattr(request, "unhandled_exception"):
|
144
|
+
|
145
|
+
path = self.get_path(request)
|
146
|
+
if path is not None:
|
138
147
|
try:
|
139
|
-
self.client.
|
148
|
+
self.client.request_counter.add_request(
|
140
149
|
consumer=consumer_identifier,
|
141
150
|
method=request.method,
|
142
151
|
path=path,
|
143
|
-
|
152
|
+
status_code=response.status_code,
|
153
|
+
response_time=response_time,
|
154
|
+
request_size=request_size,
|
155
|
+
response_size=response_size,
|
144
156
|
)
|
145
157
|
except Exception: # pragma: no cover
|
146
|
-
logger.exception("Failed to log
|
158
|
+
logger.exception("Failed to log request metadata")
|
159
|
+
|
160
|
+
if (
|
161
|
+
response.status_code == 422
|
162
|
+
and (content_type := response.get("Content-Type")) is not None
|
163
|
+
and content_type.startswith("application/json")
|
164
|
+
):
|
165
|
+
try:
|
166
|
+
with contextlib.suppress(json.JSONDecodeError):
|
167
|
+
body = json.loads(response.content)
|
168
|
+
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
169
|
+
# Log Django Ninja / Pydantic validation errors
|
170
|
+
self.client.validation_error_counter.add_validation_errors(
|
171
|
+
consumer=consumer_identifier,
|
172
|
+
method=request.method,
|
173
|
+
path=path,
|
174
|
+
detail=body["detail"],
|
175
|
+
)
|
176
|
+
except Exception: # pragma: no cover
|
177
|
+
logger.exception("Failed to log validation errors")
|
178
|
+
|
179
|
+
if response.status_code == 500 and hasattr(request, "unhandled_exception"):
|
180
|
+
try:
|
181
|
+
self.client.server_error_counter.add_server_error(
|
182
|
+
consumer=consumer_identifier,
|
183
|
+
method=request.method,
|
184
|
+
path=path,
|
185
|
+
exception=getattr(request, "unhandled_exception"),
|
186
|
+
)
|
187
|
+
except Exception: # pragma: no cover
|
188
|
+
logger.exception("Failed to log server error")
|
189
|
+
|
190
|
+
if self.client.request_logger.enabled:
|
191
|
+
self.client.request_logger.log_request(
|
192
|
+
request={
|
193
|
+
"timestamp": timestamp,
|
194
|
+
"method": request.method,
|
195
|
+
"path": path,
|
196
|
+
"url": request.build_absolute_uri(),
|
197
|
+
"headers": list(request.headers.items()),
|
198
|
+
"size": request_size,
|
199
|
+
"consumer": consumer_identifier,
|
200
|
+
"body": request_body,
|
201
|
+
},
|
202
|
+
response={
|
203
|
+
"status_code": response.status_code,
|
204
|
+
"response_time": response_time,
|
205
|
+
"headers": list(response.items()),
|
206
|
+
"size": response_size,
|
207
|
+
"body": response_body,
|
208
|
+
},
|
209
|
+
)
|
210
|
+
else:
|
211
|
+
response = self.get_response(request)
|
212
|
+
|
147
213
|
return response
|
148
214
|
|
149
215
|
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
|
apitally/django_ninja.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
from apitally.django import ApitallyConsumer, ApitallyMiddleware
|
1
|
+
from apitally.django import ApitallyConsumer, ApitallyMiddleware, RequestLoggingConfig
|
2
2
|
|
3
3
|
|
4
|
-
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
4
|
+
__all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
|
@@ -1,4 +1,4 @@
|
|
1
|
-
from apitally.django import ApitallyConsumer, ApitallyMiddleware
|
1
|
+
from apitally.django import ApitallyConsumer, ApitallyMiddleware, RequestLoggingConfig
|
2
2
|
|
3
3
|
|
4
|
-
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
4
|
+
__all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
|
apitally/fastapi.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
from apitally.starlette import ApitallyConsumer, ApitallyMiddleware
|
1
|
+
from apitally.starlette import ApitallyConsumer, ApitallyMiddleware, RequestLoggingConfig
|
2
2
|
|
3
3
|
|
4
|
-
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
4
|
+
__all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
|
apitally/flask.py
CHANGED
@@ -1,18 +1,24 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import time
|
4
|
+
from io import BytesIO
|
4
5
|
from threading import Timer
|
5
6
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
|
6
7
|
from warnings import warn
|
7
8
|
|
8
9
|
from flask import Flask, g
|
9
|
-
from flask.wrappers import Response
|
10
|
+
from flask.wrappers import Request, Response
|
10
11
|
from werkzeug.datastructures import Headers
|
11
12
|
from werkzeug.exceptions import NotFound
|
12
13
|
from werkzeug.test import Client
|
13
14
|
|
14
|
-
from apitally.client.
|
15
|
-
from apitally.client.
|
15
|
+
from apitally.client.client_threading import ApitallyClient
|
16
|
+
from apitally.client.consumers import Consumer as ApitallyConsumer
|
17
|
+
from apitally.client.request_logging import (
|
18
|
+
BODY_TOO_LARGE,
|
19
|
+
MAX_BODY_SIZE,
|
20
|
+
RequestLoggingConfig,
|
21
|
+
)
|
16
22
|
from apitally.common import get_versions
|
17
23
|
|
18
24
|
|
@@ -21,7 +27,7 @@ if TYPE_CHECKING:
|
|
21
27
|
from werkzeug.routing.map import Map
|
22
28
|
|
23
29
|
|
24
|
-
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
30
|
+
__all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
|
25
31
|
|
26
32
|
|
27
33
|
class ApitallyMiddleware:
|
@@ -30,18 +36,24 @@ class ApitallyMiddleware:
|
|
30
36
|
app: Flask,
|
31
37
|
client_id: str,
|
32
38
|
env: str = "dev",
|
39
|
+
request_logging_config: Optional[RequestLoggingConfig] = None,
|
33
40
|
app_version: Optional[str] = None,
|
34
41
|
openapi_url: Optional[str] = None,
|
35
|
-
filter_unhandled_paths: bool = True,
|
36
42
|
) -> None:
|
37
43
|
self.app = app
|
38
44
|
self.wsgi_app = app.wsgi_app
|
39
|
-
self.filter_unhandled_paths = filter_unhandled_paths
|
40
45
|
self.patch_handle_exception()
|
41
|
-
self.client = ApitallyClient(client_id=client_id, env=env)
|
46
|
+
self.client = ApitallyClient(client_id=client_id, env=env, request_logging_config=request_logging_config)
|
42
47
|
self.client.start_sync_loop()
|
43
48
|
self.delayed_set_startup_data(app_version, openapi_url)
|
44
49
|
|
50
|
+
self.capture_request_body = (
|
51
|
+
self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
|
52
|
+
)
|
53
|
+
self.capture_response_body = (
|
54
|
+
self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
|
55
|
+
)
|
56
|
+
|
45
57
|
def delayed_set_startup_data(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
|
46
58
|
# Short delay to allow app routes to be registered first
|
47
59
|
timer = Timer(
|
@@ -56,8 +68,9 @@ class ApitallyMiddleware:
|
|
56
68
|
self.client.set_startup_data(data)
|
57
69
|
|
58
70
|
def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
|
59
|
-
|
71
|
+
timestamp = time.time()
|
60
72
|
response_headers = Headers([])
|
73
|
+
status_code = 0
|
61
74
|
|
62
75
|
def catching_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
|
63
76
|
nonlocal status_code, response_headers
|
@@ -65,14 +78,41 @@ class ApitallyMiddleware:
|
|
65
78
|
response_headers = Headers(headers)
|
66
79
|
return start_response(status, headers, exc_info)
|
67
80
|
|
68
|
-
start_time = time.perf_counter()
|
69
81
|
with self.app.app_context():
|
82
|
+
request = Request(environ, populate_request=False, shallow=True)
|
83
|
+
request_size = request.content_length
|
84
|
+
request_body = b""
|
85
|
+
if self.capture_request_body:
|
86
|
+
request_body = (
|
87
|
+
_read_request_body(environ)
|
88
|
+
if request_size is not None and request_size <= MAX_BODY_SIZE
|
89
|
+
else BODY_TOO_LARGE
|
90
|
+
)
|
91
|
+
|
92
|
+
start_time = time.perf_counter()
|
70
93
|
response = self.wsgi_app(environ, catching_start_response)
|
94
|
+
response_time = time.perf_counter() - start_time
|
95
|
+
|
96
|
+
response_body = b""
|
97
|
+
if self.capture_response_body:
|
98
|
+
response_size = response_headers.get("Content-Length", type=int)
|
99
|
+
if response_size is not None and response_size > MAX_BODY_SIZE:
|
100
|
+
response_body = BODY_TOO_LARGE
|
101
|
+
else:
|
102
|
+
for chunk in response:
|
103
|
+
response_body += chunk
|
104
|
+
if len(response_body) > MAX_BODY_SIZE:
|
105
|
+
response_body = BODY_TOO_LARGE
|
106
|
+
break
|
107
|
+
|
71
108
|
self.add_request(
|
72
|
-
|
109
|
+
timestamp=timestamp,
|
110
|
+
request=request,
|
111
|
+
request_body=request_body,
|
73
112
|
status_code=status_code,
|
74
|
-
response_time=
|
113
|
+
response_time=response_time,
|
75
114
|
response_headers=response_headers,
|
115
|
+
response_body=response_body,
|
76
116
|
)
|
77
117
|
return response
|
78
118
|
|
@@ -87,41 +127,68 @@ class ApitallyMiddleware:
|
|
87
127
|
|
88
128
|
def add_request(
|
89
129
|
self,
|
90
|
-
|
130
|
+
timestamp: float,
|
131
|
+
request: Request,
|
132
|
+
request_body: bytes,
|
91
133
|
status_code: int,
|
92
134
|
response_time: float,
|
93
135
|
response_headers: Headers,
|
136
|
+
response_body: bytes,
|
94
137
|
) -> None:
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
138
|
+
path = self.get_path(request.environ)
|
139
|
+
response_size = response_headers.get("Content-Length", type=int)
|
140
|
+
|
141
|
+
consumer = self.get_consumer()
|
142
|
+
consumer_identifier = consumer.identifier if consumer else None
|
143
|
+
self.client.consumer_registry.add_or_update_consumer(consumer)
|
144
|
+
|
145
|
+
if path is not None and request.method != "OPTIONS":
|
100
146
|
self.client.request_counter.add_request(
|
101
147
|
consumer=consumer_identifier,
|
102
|
-
method=
|
103
|
-
path=
|
148
|
+
method=request.method,
|
149
|
+
path=path,
|
104
150
|
status_code=status_code,
|
105
151
|
response_time=response_time,
|
106
|
-
request_size=
|
107
|
-
response_size=
|
152
|
+
request_size=request.content_length,
|
153
|
+
response_size=response_size,
|
108
154
|
)
|
109
155
|
if status_code == 500 and "unhandled_exception" in g:
|
110
156
|
self.client.server_error_counter.add_server_error(
|
111
157
|
consumer=consumer_identifier,
|
112
|
-
method=
|
113
|
-
path=
|
158
|
+
method=request.method,
|
159
|
+
path=path,
|
114
160
|
exception=g.unhandled_exception,
|
115
161
|
)
|
116
162
|
|
117
|
-
|
163
|
+
if self.client.request_logger.enabled:
|
164
|
+
self.client.request_logger.log_request(
|
165
|
+
request={
|
166
|
+
"timestamp": timestamp,
|
167
|
+
"method": request.method,
|
168
|
+
"path": path,
|
169
|
+
"url": request.url,
|
170
|
+
"headers": list(request.headers.items()),
|
171
|
+
"size": request.content_length,
|
172
|
+
"consumer": consumer_identifier,
|
173
|
+
"body": request_body,
|
174
|
+
},
|
175
|
+
response={
|
176
|
+
"status_code": status_code,
|
177
|
+
"response_time": response_time,
|
178
|
+
"headers": list(response_headers.items()),
|
179
|
+
"size": response_size,
|
180
|
+
"body": response_body,
|
181
|
+
},
|
182
|
+
)
|
183
|
+
|
184
|
+
def get_path(self, environ: WSGIEnvironment) -> Optional[str]:
|
118
185
|
url_adapter = self.app.url_map.bind_to_environ(environ)
|
119
186
|
try:
|
120
187
|
endpoint, _ = url_adapter.match()
|
121
188
|
rule = self.app.url_map._rules_by_endpoint[endpoint][0]
|
122
|
-
return rule.rule
|
189
|
+
return rule.rule
|
123
190
|
except NotFound:
|
124
|
-
return
|
191
|
+
return None
|
125
192
|
|
126
193
|
def get_consumer(self) -> Optional[ApitallyConsumer]:
|
127
194
|
if "apitally_consumer" in g and g.apitally_consumer:
|
@@ -166,3 +233,10 @@ def _get_openapi(app: WSGIApplication, openapi_url: str) -> Optional[str]:
|
|
166
233
|
if response.status_code != 200:
|
167
234
|
return None
|
168
235
|
return response.get_data(as_text=True)
|
236
|
+
|
237
|
+
|
238
|
+
def _read_request_body(environ: WSGIEnvironment) -> bytes:
|
239
|
+
length = int(environ.get("CONTENT_LENGTH", "0"))
|
240
|
+
body = environ["wsgi.input"].read(length)
|
241
|
+
environ["wsgi.input"] = BytesIO(body)
|
242
|
+
return body
|