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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.3.4"
1
+ __version__ = "0.4.1"
@@ -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.request_logger = RequestLogger()
64
- self.validation_error_logger = ValidationErrorLogger()
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.request_logger.get_and_reset_requests()
99
- validation_errors = self.validation_error_logger.get_and_reset_validation_errors()
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 RequestLogger:
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 log_request(
152
- self, consumer: Optional[str], method: str, path: str, status_code: int, response_time: float
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 ValidationErrorLogger:
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 log_validation_errors(
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:
@@ -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.request_logger.log_request(
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.validation_error_logger.log_validation_errors(
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.log_request(
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 log_request(self, environ: WSGIEnvironment, status_code: int, response_time: float) -> None:
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.request_logger.log_request(
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.log_request(
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.log_request(
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 log_request(
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.request_logger.log_request(
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.validation_error_logger.log_validation_errors(
127
+ self.client.validation_error_counter.add_validation_errors(
126
128
  consumer=consumer,
127
129
  method=request.method,
128
130
  path=path_template,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: apitally
3
- Version: 0.3.4
3
+ Version: 0.4.1
4
4
  Summary: Simple API monitoring and API key management for REST APIs built with FastAPI, Flask, Django, and Starlette.
5
5
  Home-page: https://docs.apitally.io
6
6
  License: MIT
@@ -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,,
@@ -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,,