apitally 0.13.0__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/base.py DELETED
@@ -1,404 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import contextlib
5
- import os
6
- import re
7
- import sys
8
- import threading
9
- import time
10
- import traceback
11
- from abc import ABC
12
- from collections import Counter
13
- from dataclasses import dataclass
14
- from math import floor
15
- from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union, cast
16
- from uuid import UUID, uuid4
17
-
18
- from apitally.client.logging import get_logger
19
-
20
-
21
- logger = get_logger(__name__)
22
-
23
- HUB_BASE_URL = os.getenv("APITALLY_HUB_BASE_URL") or "https://hub.apitally.io"
24
- HUB_VERSION = "v2"
25
- REQUEST_TIMEOUT = 10
26
- MAX_QUEUE_TIME = 3600
27
- SYNC_INTERVAL = 60
28
- INITIAL_SYNC_INTERVAL = 10
29
- INITIAL_SYNC_INTERVAL_DURATION = 3600
30
- MAX_EXCEPTION_MSG_LENGTH = 2048
31
- MAX_EXCEPTION_TRACEBACK_LENGTH = 65536
32
-
33
- TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase")
34
-
35
-
36
- class ApitallyClientBase(ABC):
37
- _instance: Optional[ApitallyClientBase] = None
38
- _lock = threading.Lock()
39
-
40
- def __new__(cls: Type[TApitallyClient], *args, **kwargs) -> TApitallyClient:
41
- if cls._instance is None:
42
- with cls._lock:
43
- if cls._instance is None:
44
- cls._instance = super().__new__(cls)
45
- return cast(TApitallyClient, cls._instance)
46
-
47
- def __init__(self, client_id: str, env: str) -> None:
48
- if hasattr(self, "client_id"):
49
- raise RuntimeError("Apitally client is already initialized") # pragma: no cover
50
- try:
51
- UUID(client_id)
52
- except ValueError:
53
- raise ValueError(f"invalid client_id '{client_id}' (expecting hexadecimal UUID format)")
54
- if re.match(r"^[\w-]{1,32}$", env) is None:
55
- raise ValueError(f"invalid env '{env}' (expecting 1-32 alphanumeric lowercase characters and hyphens only)")
56
-
57
- self.client_id = client_id
58
- self.env = env
59
- self.instance_uuid = str(uuid4())
60
- self.request_counter = RequestCounter()
61
- self.validation_error_counter = ValidationErrorCounter()
62
- self.server_error_counter = ServerErrorCounter()
63
- self.consumer_registry = ConsumerRegistry()
64
-
65
- self._startup_data: Optional[Dict[str, Any]] = None
66
- self._startup_data_sent = False
67
- self._started_at = time.time()
68
-
69
- @classmethod
70
- def get_instance(cls: Type[TApitallyClient]) -> TApitallyClient:
71
- if cls._instance is None:
72
- raise RuntimeError("Apitally client not initialized") # pragma: no cover
73
- return cast(TApitallyClient, cls._instance)
74
-
75
- @property
76
- def sync_interval(self) -> float:
77
- return (
78
- SYNC_INTERVAL if time.time() - self._started_at > INITIAL_SYNC_INTERVAL_DURATION else INITIAL_SYNC_INTERVAL
79
- )
80
-
81
- @property
82
- def hub_url(self) -> str:
83
- return f"{HUB_BASE_URL}/{HUB_VERSION}/{self.client_id}/{self.env}"
84
-
85
- def add_uuids_to_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
86
- data_with_uuids = {
87
- "instance_uuid": self.instance_uuid,
88
- "message_uuid": str(uuid4()),
89
- }
90
- data_with_uuids.update(data)
91
- return data_with_uuids
92
-
93
- def get_sync_data(self) -> Dict[str, Any]:
94
- data = {
95
- "requests": self.request_counter.get_and_reset_requests(),
96
- "validation_errors": self.validation_error_counter.get_and_reset_validation_errors(),
97
- "server_errors": self.server_error_counter.get_and_reset_server_errors(),
98
- "consumers": self.consumer_registry.get_and_reset_updated_consumers(),
99
- }
100
- return self.add_uuids_to_data(data)
101
-
102
-
103
- @dataclass(frozen=True)
104
- class RequestInfo:
105
- consumer: Optional[str]
106
- method: str
107
- path: str
108
- status_code: int
109
-
110
-
111
- class RequestCounter:
112
- def __init__(self) -> None:
113
- self.request_counts: Counter[RequestInfo] = Counter()
114
- self.request_size_sums: Counter[RequestInfo] = Counter()
115
- self.response_size_sums: Counter[RequestInfo] = Counter()
116
- self.response_times: Dict[RequestInfo, Counter[int]] = {}
117
- self.request_sizes: Dict[RequestInfo, Counter[int]] = {}
118
- self.response_sizes: Dict[RequestInfo, Counter[int]] = {}
119
- self._lock = threading.Lock()
120
-
121
- def add_request(
122
- self,
123
- consumer: Optional[str],
124
- method: str,
125
- path: str,
126
- status_code: int,
127
- response_time: float,
128
- request_size: str | int | None = None,
129
- response_size: str | int | None = None,
130
- ) -> None:
131
- request_info = RequestInfo(
132
- consumer=consumer,
133
- method=method.upper(),
134
- path=path,
135
- status_code=status_code,
136
- )
137
- response_time_ms_bin = int(floor(response_time / 0.01) * 10) # In ms, rounded down to nearest 10ms
138
- with self._lock:
139
- self.request_counts[request_info] += 1
140
- self.response_times.setdefault(request_info, Counter())[response_time_ms_bin] += 1
141
- if request_size is not None:
142
- with contextlib.suppress(ValueError):
143
- request_size = int(request_size)
144
- request_size_kb_bin = request_size // 1000 # In KB, rounded down to nearest 1KB
145
- self.request_size_sums[request_info] += request_size
146
- self.request_sizes.setdefault(request_info, Counter())[request_size_kb_bin] += 1
147
- if response_size is not None:
148
- with contextlib.suppress(ValueError):
149
- response_size = int(response_size)
150
- response_size_kb_bin = response_size // 1000 # In KB, rounded down to nearest 1KB
151
- self.response_size_sums[request_info] += response_size
152
- self.response_sizes.setdefault(request_info, Counter())[response_size_kb_bin] += 1
153
-
154
- def get_and_reset_requests(self) -> List[Dict[str, Any]]:
155
- data: List[Dict[str, Any]] = []
156
- with self._lock:
157
- for request_info, count in self.request_counts.items():
158
- data.append(
159
- {
160
- "consumer": request_info.consumer,
161
- "method": request_info.method,
162
- "path": request_info.path,
163
- "status_code": request_info.status_code,
164
- "request_count": count,
165
- "request_size_sum": self.request_size_sums.get(request_info, 0),
166
- "response_size_sum": self.response_size_sums.get(request_info, 0),
167
- "response_times": self.response_times.get(request_info) or Counter(),
168
- "request_sizes": self.request_sizes.get(request_info) or Counter(),
169
- "response_sizes": self.response_sizes.get(request_info) or Counter(),
170
- }
171
- )
172
- self.request_counts.clear()
173
- self.request_size_sums.clear()
174
- self.response_size_sums.clear()
175
- self.response_times.clear()
176
- self.request_sizes.clear()
177
- self.response_sizes.clear()
178
- return data
179
-
180
-
181
- @dataclass(frozen=True)
182
- class ValidationError:
183
- consumer: Optional[str]
184
- method: str
185
- path: str
186
- loc: Tuple[str, ...]
187
- msg: str
188
- type: str
189
-
190
-
191
- class ValidationErrorCounter:
192
- def __init__(self) -> None:
193
- self.error_counts: Counter[ValidationError] = Counter()
194
- self._lock = threading.Lock()
195
-
196
- def add_validation_errors(
197
- self, consumer: Optional[str], method: str, path: str, detail: List[Dict[str, Any]]
198
- ) -> None:
199
- with self._lock:
200
- for error in detail:
201
- try:
202
- validation_error = ValidationError(
203
- consumer=consumer,
204
- method=method.upper(),
205
- path=path,
206
- loc=tuple(str(loc) for loc in error["loc"]),
207
- msg=error["msg"],
208
- type=error["type"],
209
- )
210
- self.error_counts[validation_error] += 1
211
- except (KeyError, TypeError): # pragma: no cover
212
- pass
213
-
214
- def get_and_reset_validation_errors(self) -> List[Dict[str, Any]]:
215
- data: List[Dict[str, Any]] = []
216
- with self._lock:
217
- for validation_error, count in self.error_counts.items():
218
- data.append(
219
- {
220
- "consumer": validation_error.consumer,
221
- "method": validation_error.method,
222
- "path": validation_error.path,
223
- "loc": validation_error.loc,
224
- "msg": validation_error.msg,
225
- "type": validation_error.type,
226
- "error_count": count,
227
- }
228
- )
229
- self.error_counts.clear()
230
- return data
231
-
232
-
233
- @dataclass(frozen=True)
234
- class ServerError:
235
- consumer: Optional[str]
236
- method: str
237
- path: str
238
- type: str
239
- msg: str
240
- traceback: str
241
-
242
-
243
- class ServerErrorCounter:
244
- def __init__(self) -> None:
245
- self.error_counts: Counter[ServerError] = Counter()
246
- self.sentry_event_ids: Dict[ServerError, str] = {}
247
- self._lock = threading.Lock()
248
- self._tasks: Set[asyncio.Task] = set()
249
-
250
- def add_server_error(self, consumer: Optional[str], method: str, path: str, exception: BaseException) -> None:
251
- if not isinstance(exception, BaseException):
252
- return # pragma: no cover
253
- exception_type = type(exception)
254
- with self._lock:
255
- server_error = ServerError(
256
- consumer=consumer,
257
- method=method.upper(),
258
- path=path,
259
- type=f"{exception_type.__module__}.{exception_type.__qualname__}",
260
- msg=self._get_truncated_exception_msg(exception),
261
- traceback=self._get_truncated_exception_traceback(exception),
262
- )
263
- self.error_counts[server_error] += 1
264
- self.capture_sentry_event_id(server_error)
265
-
266
- def capture_sentry_event_id(self, server_error: ServerError) -> None:
267
- try:
268
- from sentry_sdk.hub import Hub
269
- from sentry_sdk.scope import Scope
270
- except ImportError:
271
- return # pragma: no cover
272
- if not hasattr(Scope, "get_isolation_scope") or not hasattr(Scope, "_last_event_id"):
273
- # sentry-sdk < 2.2.0 is not supported
274
- return # pragma: no cover
275
- if Hub.current.client is None:
276
- return # sentry-sdk not initialized
277
-
278
- scope = Scope.get_isolation_scope()
279
- if event_id := scope._last_event_id:
280
- self.sentry_event_ids[server_error] = event_id
281
- return
282
-
283
- async def _wait_for_sentry_event_id(scope: Scope) -> None:
284
- i = 0
285
- while not (event_id := scope._last_event_id) and i < 100:
286
- i += 1
287
- await asyncio.sleep(0.001)
288
- if event_id:
289
- self.sentry_event_ids[server_error] = event_id
290
-
291
- with contextlib.suppress(RuntimeError): # ignore no running loop
292
- loop = asyncio.get_running_loop()
293
- task = loop.create_task(_wait_for_sentry_event_id(scope))
294
- self._tasks.add(task)
295
- task.add_done_callback(self._tasks.discard)
296
-
297
- def get_and_reset_server_errors(self) -> List[Dict[str, Any]]:
298
- data: List[Dict[str, Any]] = []
299
- with self._lock:
300
- for server_error, count in self.error_counts.items():
301
- data.append(
302
- {
303
- "consumer": server_error.consumer,
304
- "method": server_error.method,
305
- "path": server_error.path,
306
- "type": server_error.type,
307
- "msg": server_error.msg,
308
- "traceback": server_error.traceback,
309
- "sentry_event_id": self.sentry_event_ids.get(server_error),
310
- "error_count": count,
311
- }
312
- )
313
- self.error_counts.clear()
314
- self.sentry_event_ids.clear()
315
- return data
316
-
317
- @staticmethod
318
- def _get_truncated_exception_msg(exception: BaseException) -> str:
319
- msg = str(exception).strip()
320
- if len(msg) <= MAX_EXCEPTION_MSG_LENGTH:
321
- return msg
322
- suffix = "... (truncated)"
323
- cutoff = MAX_EXCEPTION_MSG_LENGTH - len(suffix)
324
- return msg[:cutoff] + suffix
325
-
326
- @staticmethod
327
- def _get_truncated_exception_traceback(exception: BaseException) -> str:
328
- prefix = "... (truncated) ...\n"
329
- cutoff = MAX_EXCEPTION_TRACEBACK_LENGTH - len(prefix)
330
- lines = []
331
- length = 0
332
- if sys.version_info >= (3, 10):
333
- traceback_lines = traceback.format_exception(exception)
334
- else:
335
- traceback_lines = traceback.format_exception(type(exception), exception, exception.__traceback__)
336
- for line in traceback_lines[::-1]:
337
- if length + len(line) > cutoff:
338
- lines.append(prefix)
339
- break
340
- lines.append(line)
341
- length += len(line)
342
- return "".join(lines[::-1]).strip()
343
-
344
-
345
- class Consumer:
346
- def __init__(self, identifier: str, name: Optional[str] = None, group: Optional[str] = None) -> None:
347
- self.identifier = str(identifier).strip()[:128]
348
- self.name = str(name).strip()[:64] if name else None
349
- self.group = str(group).strip()[:64] if group else None
350
-
351
- @classmethod
352
- def from_string_or_object(cls, consumer: Optional[Union[str, Consumer]]) -> Optional[Consumer]:
353
- if not consumer:
354
- return None
355
- if isinstance(consumer, Consumer):
356
- return consumer
357
- consumer = str(consumer).strip()
358
- if not consumer:
359
- return None
360
- return cls(identifier=consumer)
361
-
362
- def update(self, name: str | None = None, group: str | None = None) -> bool:
363
- name = str(name).strip()[:64] if name else None
364
- group = str(group).strip()[:64] if group else None
365
- updated = False
366
- if name and name != self.name:
367
- self.name = name
368
- updated = True
369
- if group and group != self.group:
370
- self.group = group
371
- updated = True
372
- return updated
373
-
374
-
375
- class ConsumerRegistry:
376
- def __init__(self) -> None:
377
- self.consumers: Dict[str, Consumer] = {}
378
- self.updated: Set[str] = set()
379
- self._lock = threading.Lock()
380
-
381
- def add_or_update_consumer(self, consumer: Optional[Consumer]) -> None:
382
- if not consumer or (not consumer.name and not consumer.group):
383
- return # Only register consumers with name or group set
384
- with self._lock:
385
- if consumer.identifier not in self.consumers:
386
- self.consumers[consumer.identifier] = consumer
387
- self.updated.add(consumer.identifier)
388
- elif self.consumers[consumer.identifier].update(name=consumer.name, group=consumer.group):
389
- self.updated.add(consumer.identifier)
390
-
391
- def get_and_reset_updated_consumers(self) -> List[Dict[str, Any]]:
392
- data: List[Dict[str, Any]] = []
393
- with self._lock:
394
- for identifier in self.updated:
395
- if consumer := self.consumers.get(identifier):
396
- data.append(
397
- {
398
- "identifier": consumer.identifier,
399
- "name": str(consumer.name)[:64] if consumer.name else None,
400
- "group": str(consumer.group)[:64] if consumer.group else None,
401
- }
402
- )
403
- self.updated.clear()
404
- return data
@@ -1,19 +0,0 @@
1
- apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
2
- apitally/common.py,sha256=GbVmnXxhRvV30d7CfCQ9r0AeXj14Mv9Jm_Yd1bRWP28,1088
3
- apitally/django.py,sha256=Zw8a971UwGKaEMPUtmlBbjufAYwMkSjRSQlss8FDY-E,13795
4
- apitally/django_ninja.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
5
- apitally/django_rest_framework.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
6
- apitally/fastapi.py,sha256=hEyYZsvIaA3OXZSSFdey5iqeEjfBPHgfNbyX8pLm7GI,123
7
- apitally/flask.py,sha256=7TJIoAT91-bR_7gZkL0clDk-Whl-V21hbo4nASaDmB4,6447
8
- apitally/litestar.py,sha256=O9bSzwJC-dN6ukRqyVNYBhUqxEpzie-bR2bFojcvvMI,9547
9
- apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- apitally/starlette.py,sha256=B1QTvw5nf9pdnuQda5XsCfConMN81ze8WQ0ldmiTdkc,9589
11
- apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- apitally/client/asyncio.py,sha256=Y5sbRLRnJCIJx9VQ2DGgQsYNKGvURV2U1y3VxHuPhgQ,4874
13
- apitally/client/base.py,sha256=BC_KNDuhDQcjTasvztPey9879ITcuARhgCm6HSYbGTI,15663
14
- apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
15
- apitally/client/threading.py,sha256=cASa0C9nyRp5gf5IzCDj6TE-v8t8SW4zJ38W6NdJ3Q8,5204
16
- apitally-0.13.0.dist-info/METADATA,sha256=-MZ4Xne70XtcDIT3waW4QvrMGnLdu-Sfz-CghqyJcf4,7599
17
- apitally-0.13.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
18
- apitally-0.13.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
19
- apitally-0.13.0.dist-info/RECORD,,