apitally 0.3.4__py3-none-any.whl → 0.4.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/__init__.py +1 -1
- apitally/client/asyncio.py +0 -2
- apitally/client/base.py +47 -11
- apitally/client/threading.py +0 -2
- apitally/django.py +6 -2
- apitally/flask.py +13 -5
- apitally/starlette.py +7 -5
- {apitally-0.3.4.dist-info → apitally-0.4.1.dist-info}/METADATA +1 -1
- apitally-0.4.1.dist-info/RECORD +17 -0
- apitally-0.3.4.dist-info/RECORD +0 -17
- {apitally-0.3.4.dist-info → apitally-0.4.1.dist-info}/LICENSE +0 -0
- {apitally-0.3.4.dist-info → apitally-0.4.1.dist-info}/WHEEL +0 -0
apitally/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "0.
|
1
|
+
__version__ = "0.4.1"
|
apitally/client/asyncio.py
CHANGED
@@ -36,14 +36,12 @@ class ApitallyClient(ApitallyClientBase):
|
|
36
36
|
client_id: str,
|
37
37
|
env: str,
|
38
38
|
sync_api_keys: bool = False,
|
39
|
-
sync_interval: float = 60,
|
40
39
|
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
41
40
|
) -> None:
|
42
41
|
super().__init__(
|
43
42
|
client_id=client_id,
|
44
43
|
env=env,
|
45
44
|
sync_api_keys=sync_api_keys,
|
46
|
-
sync_interval=sync_interval,
|
47
45
|
key_cache_class=key_cache_class,
|
48
46
|
)
|
49
47
|
self._stop_sync_loop = False
|
apitally/client/base.py
CHANGED
@@ -23,6 +23,9 @@ HUB_BASE_URL = os.getenv("APITALLY_HUB_BASE_URL") or "https://hub.apitally.io"
|
|
23
23
|
HUB_VERSION = "v1"
|
24
24
|
REQUEST_TIMEOUT = 10
|
25
25
|
MAX_QUEUE_TIME = 3600
|
26
|
+
SYNC_INTERVAL = 60
|
27
|
+
INITIAL_SYNC_INTERVAL = 10
|
28
|
+
INITIAL_SYNC_INTERVAL_DURATION = 3600
|
26
29
|
|
27
30
|
TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase")
|
28
31
|
|
@@ -43,7 +46,6 @@ class ApitallyClientBase:
|
|
43
46
|
client_id: str,
|
44
47
|
env: str,
|
45
48
|
sync_api_keys: bool = False,
|
46
|
-
sync_interval: float = 60,
|
47
49
|
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
48
50
|
) -> None:
|
49
51
|
if hasattr(self, "client_id"):
|
@@ -58,10 +60,9 @@ class ApitallyClientBase:
|
|
58
60
|
self.client_id = client_id
|
59
61
|
self.env = env
|
60
62
|
self.sync_api_keys = sync_api_keys
|
61
|
-
self.sync_interval = sync_interval
|
62
63
|
self.instance_uuid = str(uuid4())
|
63
|
-
self.
|
64
|
-
self.
|
64
|
+
self.request_counter = RequestCounter()
|
65
|
+
self.validation_error_counter = ValidationErrorCounter()
|
65
66
|
self.key_registry = KeyRegistry()
|
66
67
|
self.key_cache = key_cache_class(client_id=client_id, env=env) if key_cache_class is not None else None
|
67
68
|
|
@@ -82,6 +83,12 @@ class ApitallyClientBase:
|
|
82
83
|
raise RuntimeError("Apitally client not initialized") # pragma: no cover
|
83
84
|
return cast(TApitallyClient, cls._instance)
|
84
85
|
|
86
|
+
@property
|
87
|
+
def sync_interval(self) -> float:
|
88
|
+
return (
|
89
|
+
SYNC_INTERVAL if time.time() - self._started_at > INITIAL_SYNC_INTERVAL_DURATION else INITIAL_SYNC_INTERVAL
|
90
|
+
)
|
91
|
+
|
85
92
|
@property
|
86
93
|
def hub_url(self) -> str:
|
87
94
|
return f"{HUB_BASE_URL}/{HUB_VERSION}/{self.client_id}/{self.env}"
|
@@ -95,8 +102,8 @@ class ApitallyClientBase:
|
|
95
102
|
return payload
|
96
103
|
|
97
104
|
def get_requests_payload(self) -> Dict[str, Any]:
|
98
|
-
requests = self.
|
99
|
-
validation_errors = self.
|
105
|
+
requests = self.request_counter.get_and_reset_requests()
|
106
|
+
validation_errors = self.validation_error_counter.get_and_reset_validation_errors()
|
100
107
|
api_key_usage = self.key_registry.get_and_reset_usage_counts() if self.sync_api_keys else {}
|
101
108
|
return {
|
102
109
|
"instance_uuid": self.instance_uuid,
|
@@ -142,14 +149,25 @@ class RequestInfo:
|
|
142
149
|
status_code: int
|
143
150
|
|
144
151
|
|
145
|
-
class
|
152
|
+
class RequestCounter:
|
146
153
|
def __init__(self) -> None:
|
147
154
|
self.request_counts: Counter[RequestInfo] = Counter()
|
155
|
+
self.request_size_sums: Counter[RequestInfo] = Counter()
|
156
|
+
self.response_size_sums: Counter[RequestInfo] = Counter()
|
148
157
|
self.response_times: Dict[RequestInfo, Counter[int]] = {}
|
158
|
+
self.request_sizes: Dict[RequestInfo, Counter[int]] = {}
|
159
|
+
self.response_sizes: Dict[RequestInfo, Counter[int]] = {}
|
149
160
|
self._lock = threading.Lock()
|
150
161
|
|
151
|
-
def
|
152
|
-
self,
|
162
|
+
def add_request(
|
163
|
+
self,
|
164
|
+
consumer: Optional[str],
|
165
|
+
method: str,
|
166
|
+
path: str,
|
167
|
+
status_code: int,
|
168
|
+
response_time: float,
|
169
|
+
request_size: str | int | None = None,
|
170
|
+
response_size: str | int | None = None,
|
153
171
|
) -> None:
|
154
172
|
request_info = RequestInfo(
|
155
173
|
consumer=consumer,
|
@@ -161,6 +179,16 @@ class RequestLogger:
|
|
161
179
|
with self._lock:
|
162
180
|
self.request_counts[request_info] += 1
|
163
181
|
self.response_times.setdefault(request_info, Counter())[response_time_ms_bin] += 1
|
182
|
+
if request_size is not None:
|
183
|
+
request_size = int(request_size)
|
184
|
+
request_size_kb_bin = request_size // 1000 # In KB, rounded down to nearest 1KB
|
185
|
+
self.request_size_sums[request_info] += request_size
|
186
|
+
self.request_sizes.setdefault(request_info, Counter())[request_size_kb_bin] += 1
|
187
|
+
if response_size is not None:
|
188
|
+
response_size = int(response_size)
|
189
|
+
response_size_kb_bin = response_size // 1000 # In KB, rounded down to nearest 1KB
|
190
|
+
self.response_size_sums[request_info] += response_size
|
191
|
+
self.response_sizes.setdefault(request_info, Counter())[response_size_kb_bin] += 1
|
164
192
|
|
165
193
|
def get_and_reset_requests(self) -> List[Dict[str, Any]]:
|
166
194
|
data: List[Dict[str, Any]] = []
|
@@ -173,11 +201,19 @@ class RequestLogger:
|
|
173
201
|
"path": request_info.path,
|
174
202
|
"status_code": request_info.status_code,
|
175
203
|
"request_count": count,
|
204
|
+
"request_size_sum": self.request_size_sums.get(request_info, 0),
|
205
|
+
"response_size_sum": self.response_size_sums.get(request_info, 0),
|
176
206
|
"response_times": self.response_times.get(request_info) or Counter(),
|
207
|
+
"request_sizes": self.request_sizes.get(request_info) or Counter(),
|
208
|
+
"response_sizes": self.response_sizes.get(request_info) or Counter(),
|
177
209
|
}
|
178
210
|
)
|
179
211
|
self.request_counts.clear()
|
212
|
+
self.request_size_sums.clear()
|
213
|
+
self.response_size_sums.clear()
|
180
214
|
self.response_times.clear()
|
215
|
+
self.request_sizes.clear()
|
216
|
+
self.response_sizes.clear()
|
181
217
|
return data
|
182
218
|
|
183
219
|
|
@@ -191,12 +227,12 @@ class ValidationError:
|
|
191
227
|
type: str
|
192
228
|
|
193
229
|
|
194
|
-
class
|
230
|
+
class ValidationErrorCounter:
|
195
231
|
def __init__(self) -> None:
|
196
232
|
self.error_counts: Counter[ValidationError] = Counter()
|
197
233
|
self._lock = threading.Lock()
|
198
234
|
|
199
|
-
def
|
235
|
+
def add_validation_errors(
|
200
236
|
self, consumer: Optional[str], method: str, path: str, detail: List[Dict[str, Any]]
|
201
237
|
) -> None:
|
202
238
|
with self._lock:
|
apitally/client/threading.py
CHANGED
@@ -53,14 +53,12 @@ class ApitallyClient(ApitallyClientBase):
|
|
53
53
|
client_id: str,
|
54
54
|
env: str,
|
55
55
|
sync_api_keys: bool = False,
|
56
|
-
sync_interval: float = 60,
|
57
56
|
key_cache_class: Optional[Type[ApitallyKeyCacheBase]] = None,
|
58
57
|
) -> None:
|
59
58
|
super().__init__(
|
60
59
|
client_id=client_id,
|
61
60
|
env=env,
|
62
61
|
sync_api_keys=sync_api_keys,
|
63
|
-
sync_interval=sync_interval,
|
64
62
|
key_cache_class=key_cache_class,
|
65
63
|
)
|
66
64
|
self._thread: Optional[Thread] = None
|
apitally/django.py
CHANGED
@@ -89,12 +89,16 @@ class ApitallyMiddleware:
|
|
89
89
|
response = self.get_response(request)
|
90
90
|
if request.method is not None and view is not None and view.is_api_view:
|
91
91
|
consumer = self.get_consumer(request)
|
92
|
-
self.client.
|
92
|
+
self.client.request_counter.add_request(
|
93
93
|
consumer=consumer,
|
94
94
|
method=request.method,
|
95
95
|
path=view.pattern,
|
96
96
|
status_code=response.status_code,
|
97
97
|
response_time=time.perf_counter() - start_time,
|
98
|
+
request_size=request.headers.get("Content-Length"),
|
99
|
+
response_size=response.headers.get("Content-Length") # type: ignore[attr-defined]
|
100
|
+
if response.has_header("Content-Length")
|
101
|
+
else (len(response.content) if not response.streaming else None),
|
98
102
|
)
|
99
103
|
if (
|
100
104
|
response.status_code == 422
|
@@ -105,7 +109,7 @@ class ApitallyMiddleware:
|
|
105
109
|
body = json.loads(response.content)
|
106
110
|
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
107
111
|
# Log Django Ninja / Pydantic validation errors
|
108
|
-
self.client.
|
112
|
+
self.client.validation_error_counter.add_validation_errors(
|
109
113
|
consumer=consumer,
|
110
114
|
method=request.method,
|
111
115
|
path=view.pattern,
|
apitally/flask.py
CHANGED
@@ -8,6 +8,7 @@ from threading import Timer
|
|
8
8
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Type
|
9
9
|
|
10
10
|
from flask import Flask, g, make_response, request
|
11
|
+
from werkzeug.datastructures import Headers
|
11
12
|
from werkzeug.exceptions import NotFound
|
12
13
|
from werkzeug.test import Client
|
13
14
|
|
@@ -58,31 +59,38 @@ class ApitallyMiddleware:
|
|
58
59
|
|
59
60
|
def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
|
60
61
|
status_code = 200
|
62
|
+
response_headers = Headers([])
|
61
63
|
|
62
|
-
def catching_start_response(status: str, headers, exc_info=None):
|
63
|
-
nonlocal status_code
|
64
|
+
def catching_start_response(status: str, headers: List[Tuple[str, str]], exc_info=None):
|
65
|
+
nonlocal status_code, response_headers
|
64
66
|
status_code = int(status.split(" ")[0])
|
67
|
+
response_headers = Headers(headers)
|
65
68
|
return start_response(status, headers, exc_info)
|
66
69
|
|
67
70
|
start_time = time.perf_counter()
|
68
71
|
with self.app.app_context():
|
69
72
|
response = self.wsgi_app(environ, catching_start_response)
|
70
|
-
self.
|
73
|
+
self.add_request(
|
71
74
|
environ=environ,
|
72
75
|
status_code=status_code,
|
73
76
|
response_time=time.perf_counter() - start_time,
|
77
|
+
response_headers=response_headers,
|
74
78
|
)
|
75
79
|
return response
|
76
80
|
|
77
|
-
def
|
81
|
+
def add_request(
|
82
|
+
self, environ: WSGIEnvironment, status_code: int, response_time: float, response_headers: Headers
|
83
|
+
) -> None:
|
78
84
|
rule, is_handled_path = self.get_rule(environ)
|
79
85
|
if is_handled_path or not self.filter_unhandled_paths:
|
80
|
-
self.client.
|
86
|
+
self.client.request_counter.add_request(
|
81
87
|
consumer=self.get_consumer(),
|
82
88
|
method=environ["REQUEST_METHOD"],
|
83
89
|
path=rule,
|
84
90
|
status_code=status_code,
|
85
91
|
response_time=response_time,
|
92
|
+
request_size=environ.get("CONTENT_LENGTH"),
|
93
|
+
response_size=response_headers.get("Content-Length", type=int),
|
86
94
|
)
|
87
95
|
|
88
96
|
def get_rule(self, environ: WSGIEnvironment) -> Tuple[str, bool]:
|
apitally/starlette.py
CHANGED
@@ -85,7 +85,7 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
85
85
|
start_time = time.perf_counter()
|
86
86
|
response = await call_next(request)
|
87
87
|
except BaseException as e:
|
88
|
-
await self.
|
88
|
+
await self.add_request(
|
89
89
|
request=request,
|
90
90
|
response=None,
|
91
91
|
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
|
@@ -93,7 +93,7 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
93
93
|
)
|
94
94
|
raise e from None
|
95
95
|
else:
|
96
|
-
await self.
|
96
|
+
await self.add_request(
|
97
97
|
request=request,
|
98
98
|
response=response,
|
99
99
|
status_code=response.status_code,
|
@@ -101,18 +101,20 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
101
101
|
)
|
102
102
|
return response
|
103
103
|
|
104
|
-
async def
|
104
|
+
async def add_request(
|
105
105
|
self, request: Request, response: Optional[Response], status_code: int, response_time: float
|
106
106
|
) -> None:
|
107
107
|
path_template, is_handled_path = self.get_path_template(request)
|
108
108
|
if is_handled_path or not self.filter_unhandled_paths:
|
109
109
|
consumer = self.get_consumer(request)
|
110
|
-
self.client.
|
110
|
+
self.client.request_counter.add_request(
|
111
111
|
consumer=consumer,
|
112
112
|
method=request.method,
|
113
113
|
path=path_template,
|
114
114
|
status_code=status_code,
|
115
115
|
response_time=response_time,
|
116
|
+
request_size=request.headers.get("Content-Length"),
|
117
|
+
response_size=response.headers.get("Content-Length") if response is not None else None,
|
116
118
|
)
|
117
119
|
if (
|
118
120
|
status_code == 422
|
@@ -122,7 +124,7 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
122
124
|
body = await self.get_response_json(response)
|
123
125
|
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
124
126
|
# Log FastAPI / Pydantic validation errors
|
125
|
-
self.client.
|
127
|
+
self.client.validation_error_counter.add_validation_errors(
|
126
128
|
consumer=consumer,
|
127
129
|
method=request.method,
|
128
130
|
path=path_template,
|
@@ -0,0 +1,17 @@
|
|
1
|
+
apitally/__init__.py,sha256=pMtTmSUht-XtbR_7Doz6bsQqopJJd8rZ8I8zy2HwwoA,22
|
2
|
+
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
apitally/client/asyncio.py,sha256=A7FEk-VD7YY2UPVBNaI8Roh3e0YLAVpD5iU6wS5067I,5977
|
4
|
+
apitally/client/base.py,sha256=iEBFWId-M6_V1roAE4z270iwdZQJ48NZ7trAOSEAUBk,12489
|
5
|
+
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
6
|
+
apitally/client/threading.py,sha256=CALCE4AGMMedhR5GpwiPqY4BZIiIu3TrIa-H2dzpxvg,6396
|
7
|
+
apitally/django.py,sha256=i2mb7sHes6Q4JE1kdo1BvDBBcGZzuv946e4MDlAE62c,10555
|
8
|
+
apitally/django_ninja.py,sha256=TFltgr03FzTnl83sUAXJj7R32u_g9DTZ9p-HuVKs4ZE,2785
|
9
|
+
apitally/django_rest_framework.py,sha256=UmJvxxiKGRdaILSbg6jJY_cvAl-mpuPY1pM0FoQ4bg0,1587
|
10
|
+
apitally/fastapi.py,sha256=YjnrRis8UG2M6Q3lkwizbtDXU7nPfCA4mebxG8XwveY,3334
|
11
|
+
apitally/flask.py,sha256=C3_Uk0XiVyayXURnzKvoG7EAFthA6PeV7mLys22BiO4,6960
|
12
|
+
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
|
+
apitally/starlette.py,sha256=8PQdvw0h0_rBVziPciMGtHf0vxJ_mlrKOSyFAB7Mr14,9941
|
14
|
+
apitally-0.4.1.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
15
|
+
apitally-0.4.1.dist-info/METADATA,sha256=J-oAJmEw8L92xjTx3rCZhz7xvZWn4QW5PG9GL9eHhpY,6983
|
16
|
+
apitally-0.4.1.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
17
|
+
apitally-0.4.1.dist-info/RECORD,,
|
apitally-0.3.4.dist-info/RECORD
DELETED
@@ -1,17 +0,0 @@
|
|
1
|
-
apitally/__init__.py,sha256=oYLGMpySamd16KLiaBTfRyrAS7_oyp-TOEHmzmeumwg,22
|
2
|
-
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
apitally/client/asyncio.py,sha256=ntn9TVhMKPtwrw1C1Qv96Enr_nSsaJHYmyCrbXw-sBc,6053
|
4
|
-
apitally/client/base.py,sha256=ndllCC9rQsAwGYlzYn7kSCarBX0rEKWp44BMUop1t7w,10630
|
5
|
-
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
6
|
-
apitally/client/threading.py,sha256=9xYW095OXWrX4kciAbbrztc2DeNwRPvMf7zvcbes5Ec,6472
|
7
|
-
apitally/django.py,sha256=Y1VLlLLla89z3sjs7zCHwnCGCZ1VScw7AkcjqtrnNNY,10247
|
8
|
-
apitally/django_ninja.py,sha256=TFltgr03FzTnl83sUAXJj7R32u_g9DTZ9p-HuVKs4ZE,2785
|
9
|
-
apitally/django_rest_framework.py,sha256=UmJvxxiKGRdaILSbg6jJY_cvAl-mpuPY1pM0FoQ4bg0,1587
|
10
|
-
apitally/fastapi.py,sha256=YjnrRis8UG2M6Q3lkwizbtDXU7nPfCA4mebxG8XwveY,3334
|
11
|
-
apitally/flask.py,sha256=SB0GM9KQbqLh1mEwUkI7jZ8BJS79l3cYjxXjYr7lw0c,6555
|
12
|
-
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
|
-
apitally/starlette.py,sha256=T1aHf5L-sqVlYjPRPO8HbrvYrn98eeIrzBGAOEKJSEU,9767
|
14
|
-
apitally-0.3.4.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
15
|
-
apitally-0.3.4.dist-info/METADATA,sha256=N_AO1I5JF6v80cN1uKLu_Ef__kEAmVI3X01LXYVb15Q,6983
|
16
|
-
apitally-0.3.4.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
17
|
-
apitally-0.3.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|