apitally 0.14.5__py3-none-any.whl → 0.15.0__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.
@@ -81,6 +81,7 @@ class ApitallyClient(ApitallyClientBase):
81
81
  self._stop_sync_loop = True
82
82
 
83
83
  async def handle_shutdown(self) -> None:
84
+ self.enabled = False
84
85
  if self._sync_loop_task is not None:
85
86
  self._sync_loop_task.cancel()
86
87
  # Send any remaining data before exiting
@@ -164,6 +165,7 @@ class ApitallyClient(ApitallyClientBase):
164
165
 
165
166
  def _handle_hub_response(self, response: httpx.Response) -> None:
166
167
  if response.status_code == 404:
168
+ self.enabled = False
167
169
  self.stop_sync_loop()
168
170
  logger.error("Invalid Apitally client ID: %s", self.client_id)
169
171
  elif response.status_code == 422:
@@ -52,6 +52,7 @@ class ApitallyClientBase(ABC):
52
52
 
53
53
  self.client_id = client_id
54
54
  self.env = env
55
+ self.enabled = True
55
56
  self.instance_uuid = str(uuid4())
56
57
  self.request_counter = RequestCounter()
57
58
  self.validation_error_counter = ValidationErrorCounter()
@@ -190,6 +190,7 @@ class ApitallyClient(ApitallyClientBase):
190
190
 
191
191
  def _handle_hub_response(self, response: requests.Response) -> None:
192
192
  if response.status_code == 404:
193
+ self.enabled = False
193
194
  self.stop_sync_loop()
194
195
  logger.error("Invalid Apitally client ID: %s", self.client_id)
195
196
  elif response.status_code == 422:
@@ -14,6 +14,12 @@ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
14
14
  from uuid import uuid4
15
15
 
16
16
  from apitally.client.logging import get_logger
17
+ from apitally.client.sentry import get_sentry_event_id_async
18
+ from apitally.client.server_errors import (
19
+ get_exception_type,
20
+ get_truncated_exception_msg,
21
+ get_truncated_exception_traceback,
22
+ )
17
23
 
18
24
 
19
25
  logger = get_logger(__name__)
@@ -88,6 +94,7 @@ class RequestLoggingConfig:
88
94
  log_request_body: Whether to log the request body (only if JSON or plain text)
89
95
  log_response_headers: Whether to log response header values
90
96
  log_response_body: Whether to log the response body (only if JSON or plain text)
97
+ log_exception: Whether to log unhandled exceptions in case of server errors
91
98
  mask_query_params: Query parameter names to mask in logs. Expects regular expressions.
92
99
  mask_headers: Header names to mask in logs. Expects regular expressions.
93
100
  mask_request_body_callback: Callback to mask the request body. Expects (method, path, body) and returns the masked body as bytes or None.
@@ -102,6 +109,7 @@ class RequestLoggingConfig:
102
109
  log_request_body: bool = False
103
110
  log_response_headers: bool = True
104
111
  log_response_body: bool = False
112
+ log_exception: bool = True
105
113
  mask_query_params: List[str] = field(default_factory=list)
106
114
  mask_headers: List[str] = field(default_factory=list)
107
115
  mask_request_body_callback: Optional[Callable[[RequestDict], Optional[bytes]]] = None
@@ -153,7 +161,7 @@ class RequestLogger:
153
161
  self.config = config or RequestLoggingConfig()
154
162
  self.enabled = self.config.enabled and _check_writable_fs()
155
163
  self.serialize = _get_json_serializer()
156
- self.write_deque: deque[bytes] = deque([], MAX_REQUESTS_IN_DEQUE)
164
+ self.write_deque: deque[Dict[str, Any]] = deque([], MAX_REQUESTS_IN_DEQUE)
157
165
  self.file_deque: deque[TempGzipFile] = deque([])
158
166
  self.file: Optional[TempGzipFile] = None
159
167
  self.lock = threading.Lock()
@@ -163,7 +171,9 @@ class RequestLogger:
163
171
  def current_file_size(self) -> int:
164
172
  return self.file.size if self.file is not None else 0
165
173
 
166
- def log_request(self, request: RequestDict, response: ResponseDict) -> None:
174
+ def log_request(
175
+ self, request: RequestDict, response: ResponseDict, exception: Optional[BaseException] = None
176
+ ) -> None:
167
177
  if not self.enabled or self.suspend_until is not None:
168
178
  return
169
179
  parsed_url = urlparse(request["url"])
@@ -177,8 +187,6 @@ class RequestLogger:
177
187
 
178
188
  query = self._mask_query_params(parsed_url.query) if self.config.log_query_params else ""
179
189
  request["url"] = urlunparse(parsed_url._replace(query=query))
180
- request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
181
- response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
182
190
 
183
191
  if not self.config.log_request_body or not self._has_supported_content_type(request["headers"]):
184
192
  request["body"] = None
@@ -214,13 +222,22 @@ class RequestLogger:
214
222
  if response["body"] is not None and len(response["body"]) > MAX_BODY_SIZE:
215
223
  response["body"] = BODY_TOO_LARGE
216
224
 
217
- item = {
225
+ request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
226
+ response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
227
+
228
+ item: Dict[str, Any] = {
218
229
  "uuid": str(uuid4()),
219
230
  "request": _skip_empty_values(request),
220
231
  "response": _skip_empty_values(response),
221
232
  }
222
- serialized_item = self.serialize(item)
223
- self.write_deque.append(serialized_item)
233
+ if exception is not None and self.config.log_exception:
234
+ item["exception"] = {
235
+ "type": get_exception_type(exception),
236
+ "message": get_truncated_exception_msg(exception),
237
+ "traceback": get_truncated_exception_traceback(exception),
238
+ }
239
+ get_sentry_event_id_async(lambda event_id: item["exception"].update({"sentry_event_id": event_id}))
240
+ self.write_deque.append(item)
224
241
 
225
242
  def write_to_file(self) -> None:
226
243
  if not self.enabled or len(self.write_deque) == 0:
@@ -231,7 +248,7 @@ class RequestLogger:
231
248
  while True:
232
249
  try:
233
250
  item = self.write_deque.popleft()
234
- self.file.write_line(item)
251
+ self.file.write_line(self.serialize(item))
235
252
  except IndexError:
236
253
  break
237
254
 
@@ -310,14 +327,18 @@ class RequestLogger:
310
327
  return True
311
328
  return False
312
329
 
330
+ @staticmethod
331
+ def _get_user_agent(headers: List[Tuple[str, str]]) -> Optional[str]:
332
+ return next((v for k, v in headers if k.lower() == "user-agent"), None)
333
+
313
334
  @staticmethod
314
335
  def _has_supported_content_type(headers: List[Tuple[str, str]]) -> bool:
315
336
  content_type = next((v for k, v in headers if k.lower() == "content-type"), None)
316
- return content_type is not None and any(content_type.startswith(t) for t in ALLOWED_CONTENT_TYPES)
337
+ return RequestLogger.is_supported_content_type(content_type)
317
338
 
318
339
  @staticmethod
319
- def _get_user_agent(headers: List[Tuple[str, str]]) -> Optional[str]:
320
- return next((v for k, v in headers if k.lower() == "user-agent"), None)
340
+ def is_supported_content_type(content_type: Optional[str]) -> bool:
341
+ return content_type is not None and any(content_type.startswith(t) for t in ALLOWED_CONTENT_TYPES)
321
342
 
322
343
 
323
344
  def _check_writable_fs() -> bool:
@@ -0,0 +1,38 @@
1
+ import asyncio
2
+ import contextlib
3
+ from typing import Callable, Set
4
+
5
+
6
+ _tasks: Set[asyncio.Task] = set()
7
+
8
+
9
+ def get_sentry_event_id_async(cb: Callable[[str], None]) -> None:
10
+ try:
11
+ from sentry_sdk.hub import Hub
12
+ from sentry_sdk.scope import Scope
13
+ except ImportError:
14
+ return # pragma: no cover
15
+ if not hasattr(Scope, "get_isolation_scope") or not hasattr(Scope, "_last_event_id"):
16
+ # sentry-sdk < 2.2.0 is not supported
17
+ return # pragma: no cover
18
+ if Hub.current.client is None:
19
+ return # sentry-sdk not initialized
20
+
21
+ scope = Scope.get_isolation_scope()
22
+ if event_id := scope._last_event_id:
23
+ cb(event_id)
24
+ return
25
+
26
+ async def _wait_for_sentry_event_id(scope: Scope) -> None:
27
+ i = 0
28
+ while not (event_id := scope._last_event_id) and i < 100:
29
+ i += 1
30
+ await asyncio.sleep(0.001)
31
+ if event_id:
32
+ cb(event_id)
33
+
34
+ with contextlib.suppress(RuntimeError): # ignore no running loop
35
+ loop = asyncio.get_running_loop()
36
+ task = loop.create_task(_wait_for_sentry_event_id(scope))
37
+ _tasks.add(task)
38
+ task.add_done_callback(_tasks.discard)
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import contextlib
5
4
  import sys
6
5
  import threading
7
6
  import traceback
@@ -9,6 +8,8 @@ from collections import Counter
9
8
  from dataclasses import dataclass
10
9
  from typing import Any, Dict, List, Optional, Set
11
10
 
11
+ from apitally.client.sentry import get_sentry_event_id_async
12
+
12
13
 
13
14
  MAX_EXCEPTION_MSG_LENGTH = 2048
14
15
  MAX_EXCEPTION_TRACEBACK_LENGTH = 65536
@@ -34,49 +35,17 @@ class ServerErrorCounter:
34
35
  def add_server_error(self, consumer: Optional[str], method: str, path: str, exception: BaseException) -> None:
35
36
  if not isinstance(exception, BaseException):
36
37
  return # pragma: no cover
37
- exception_type = type(exception)
38
38
  with self._lock:
39
39
  server_error = ServerError(
40
40
  consumer=consumer,
41
41
  method=method.upper(),
42
42
  path=path,
43
- type=f"{exception_type.__module__}.{exception_type.__qualname__}",
44
- msg=self._get_truncated_exception_msg(exception),
45
- traceback=self._get_truncated_exception_traceback(exception),
43
+ type=get_exception_type(exception),
44
+ msg=get_truncated_exception_msg(exception),
45
+ traceback=get_truncated_exception_traceback(exception),
46
46
  )
47
47
  self.error_counts[server_error] += 1
48
- self.capture_sentry_event_id(server_error)
49
-
50
- def capture_sentry_event_id(self, server_error: ServerError) -> None:
51
- try:
52
- from sentry_sdk.hub import Hub
53
- from sentry_sdk.scope import Scope
54
- except ImportError:
55
- return # pragma: no cover
56
- if not hasattr(Scope, "get_isolation_scope") or not hasattr(Scope, "_last_event_id"):
57
- # sentry-sdk < 2.2.0 is not supported
58
- return # pragma: no cover
59
- if Hub.current.client is None:
60
- return # sentry-sdk not initialized
61
-
62
- scope = Scope.get_isolation_scope()
63
- if event_id := scope._last_event_id:
64
- self.sentry_event_ids[server_error] = event_id
65
- return
66
-
67
- async def _wait_for_sentry_event_id(scope: Scope) -> None:
68
- i = 0
69
- while not (event_id := scope._last_event_id) and i < 100:
70
- i += 1
71
- await asyncio.sleep(0.001)
72
- if event_id:
73
- self.sentry_event_ids[server_error] = event_id
74
-
75
- with contextlib.suppress(RuntimeError): # ignore no running loop
76
- loop = asyncio.get_running_loop()
77
- task = loop.create_task(_wait_for_sentry_event_id(scope))
78
- self._tasks.add(task)
79
- task.add_done_callback(self._tasks.discard)
48
+ get_sentry_event_id_async(lambda event_id: self.sentry_event_ids.update({server_error: event_id}))
80
49
 
81
50
  def get_and_reset_server_errors(self) -> List[Dict[str, Any]]:
82
51
  data: List[Dict[str, Any]] = []
@@ -98,29 +67,34 @@ class ServerErrorCounter:
98
67
  self.sentry_event_ids.clear()
99
68
  return data
100
69
 
101
- @staticmethod
102
- def _get_truncated_exception_msg(exception: BaseException) -> str:
103
- msg = str(exception).strip()
104
- if len(msg) <= MAX_EXCEPTION_MSG_LENGTH:
105
- return msg
106
- suffix = "... (truncated)"
107
- cutoff = MAX_EXCEPTION_MSG_LENGTH - len(suffix)
108
- return msg[:cutoff] + suffix
109
-
110
- @staticmethod
111
- def _get_truncated_exception_traceback(exception: BaseException) -> str:
112
- prefix = "... (truncated) ...\n"
113
- cutoff = MAX_EXCEPTION_TRACEBACK_LENGTH - len(prefix)
114
- lines = []
115
- length = 0
116
- if sys.version_info >= (3, 10):
117
- traceback_lines = traceback.format_exception(exception)
118
- else:
119
- traceback_lines = traceback.format_exception(type(exception), exception, exception.__traceback__)
120
- for line in traceback_lines[::-1]:
121
- if length + len(line) > cutoff:
122
- lines.append(prefix)
123
- break
124
- lines.append(line)
125
- length += len(line)
126
- return "".join(lines[::-1]).strip()
70
+
71
+ def get_exception_type(exception: BaseException) -> str:
72
+ exception_type = type(exception)
73
+ return f"{exception_type.__module__}.{exception_type.__qualname__}"
74
+
75
+
76
+ def get_truncated_exception_msg(exception: BaseException) -> str:
77
+ msg = str(exception).strip()
78
+ if len(msg) <= MAX_EXCEPTION_MSG_LENGTH:
79
+ return msg
80
+ suffix = "... (truncated)"
81
+ cutoff = MAX_EXCEPTION_MSG_LENGTH - len(suffix)
82
+ return msg[:cutoff] + suffix
83
+
84
+
85
+ def get_truncated_exception_traceback(exception: BaseException) -> str:
86
+ prefix = "... (truncated) ...\n"
87
+ cutoff = MAX_EXCEPTION_TRACEBACK_LENGTH - len(prefix)
88
+ lines = []
89
+ length = 0
90
+ if sys.version_info >= (3, 10):
91
+ traceback_lines = traceback.format_exception(exception)
92
+ else:
93
+ traceback_lines = traceback.format_exception(type(exception), exception, exception.__traceback__)
94
+ for line in traceback_lines[::-1]:
95
+ if length + len(line) > cutoff:
96
+ lines.append(prefix)
97
+ break
98
+ lines.append(line)
99
+ length += len(line)
100
+ return "".join(lines[::-1]).strip()
apitally/django.py CHANGED
@@ -19,6 +19,7 @@ from apitally.client.logging import get_logger
19
19
  from apitally.client.request_logging import (
20
20
  BODY_TOO_LARGE,
21
21
  MAX_BODY_SIZE,
22
+ RequestLogger,
22
23
  RequestLoggingConfig,
23
24
  )
24
25
  from apitally.common import get_versions, parse_int
@@ -113,7 +114,7 @@ class ApitallyMiddleware:
113
114
  )
114
115
 
115
116
  def __call__(self, request: HttpRequest) -> HttpResponse:
116
- if request.method is not None and request.method != "OPTIONS":
117
+ if self.client.enabled and request.method is not None and request.method != "OPTIONS":
117
118
  timestamp = time.time()
118
119
  request_size = parse_int(request.headers.get("Content-Length"))
119
120
  request_body = b""
@@ -133,7 +134,12 @@ class ApitallyMiddleware:
133
134
  else (len(response.content) if not response.streaming else None)
134
135
  )
135
136
  response_body = b""
136
- if self.capture_response_body and not response.streaming:
137
+ response_content_type = response.get("Content-Type")
138
+ if (
139
+ self.capture_response_body
140
+ and not response.streaming
141
+ and RequestLogger.is_supported_content_type(response_content_type)
142
+ ):
137
143
  response_body = (
138
144
  response.content if response_size is not None and response_size <= MAX_BODY_SIZE else BODY_TOO_LARGE
139
145
  )
@@ -210,6 +216,7 @@ class ApitallyMiddleware:
210
216
  "size": response_size,
211
217
  "body": response_body,
212
218
  },
219
+ exception=getattr(request, "unhandled_exception", None),
213
220
  )
214
221
  else:
215
222
  response = self.get_response(request)
apitally/flask.py CHANGED
@@ -17,6 +17,7 @@ from apitally.client.consumers import Consumer as ApitallyConsumer
17
17
  from apitally.client.request_logging import (
18
18
  BODY_TOO_LARGE,
19
19
  MAX_BODY_SIZE,
20
+ RequestLogger,
20
21
  RequestLoggingConfig,
21
22
  )
22
23
  from apitally.common import get_versions
@@ -74,6 +75,9 @@ class ApitallyMiddleware:
74
75
  self.client.set_startup_data(data)
75
76
 
76
77
  def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
78
+ if not self.client.enabled:
79
+ return self.wsgi_app(environ, start_response)
80
+
77
81
  timestamp = time.time()
78
82
  response_headers = Headers([])
79
83
  status_code = 0
@@ -100,7 +104,8 @@ class ApitallyMiddleware:
100
104
  response_time = time.perf_counter() - start_time
101
105
 
102
106
  response_body = b""
103
- if self.capture_response_body:
107
+ response_content_type = response_headers.get("Content-Type")
108
+ if self.capture_response_body and RequestLogger.is_supported_content_type(response_content_type):
104
109
  response_size = response_headers.get("Content-Length", type=int)
105
110
  if response_size is not None and response_size > MAX_BODY_SIZE:
106
111
  response_body = BODY_TOO_LARGE
@@ -185,6 +190,7 @@ class ApitallyMiddleware:
185
190
  "size": response_size,
186
191
  "body": response_body,
187
192
  },
193
+ exception=g.unhandled_exception if "unhandled_exception" in g else None,
188
194
  )
189
195
 
190
196
  def get_path(self, environ: WSGIEnvironment) -> Optional[str]:
apitally/litestar.py CHANGED
@@ -19,6 +19,7 @@ from apitally.client.consumers import Consumer as ApitallyConsumer
19
19
  from apitally.client.request_logging import (
20
20
  BODY_TOO_LARGE,
21
21
  MAX_BODY_SIZE,
22
+ RequestLogger,
22
23
  RequestLoggingConfig,
23
24
  )
24
25
  from apitally.common import get_versions, parse_int
@@ -86,7 +87,7 @@ class ApitallyPlugin(InitPluginProtocol):
86
87
 
87
88
  def middleware_factory(self, app: ASGIApp) -> ASGIApp:
88
89
  async def middleware(scope: Scope, receive: Receive, send: Send) -> None:
89
- if scope["type"] == "http" and scope["method"] != "OPTIONS":
90
+ if self.client.enabled and scope["type"] == "http" and scope["method"] != "OPTIONS":
90
91
  timestamp = time.time()
91
92
  request = Request(scope)
92
93
  request_size = parse_int(request.headers.get("Content-Length"))
@@ -99,6 +100,7 @@ class ApitallyPlugin(InitPluginProtocol):
99
100
  response_body_too_large = False
100
101
  response_size: Optional[int] = None
101
102
  response_chunked = False
103
+ response_content_type: Optional[str] = None
102
104
  start_time = time.perf_counter()
103
105
 
104
106
  async def receive_wrapper() -> Message:
@@ -119,6 +121,7 @@ class ApitallyPlugin(InitPluginProtocol):
119
121
  response_body, \
120
122
  response_body_too_large, \
121
123
  response_chunked, \
124
+ response_content_type, \
122
125
  response_size
123
126
  if message["type"] == "http.response.start":
124
127
  response_time = time.perf_counter() - start_time
@@ -128,12 +131,17 @@ class ApitallyPlugin(InitPluginProtocol):
128
131
  response_headers.get("Transfer-Encoding") == "chunked"
129
132
  or "Content-Length" not in response_headers
130
133
  )
134
+ response_content_type = response_headers.get("Content-Type")
131
135
  response_size = parse_int(response_headers.get("Content-Length")) if not response_chunked else 0
132
136
  response_body_too_large = response_size is not None and response_size > MAX_BODY_SIZE
133
137
  elif message["type"] == "http.response.body":
134
138
  if response_chunked and response_size is not None:
135
139
  response_size += len(message.get("body", b""))
136
- if (self.capture_response_body or response_status == 400) and not response_body_too_large:
140
+ if (
141
+ (self.capture_response_body or response_status == 400)
142
+ and RequestLogger.is_supported_content_type(response_content_type)
143
+ and not response_body_too_large
144
+ ):
137
145
  response_body += message.get("body", b"")
138
146
  if len(response_body) > MAX_BODY_SIZE:
139
147
  response_body_too_large = True
@@ -243,6 +251,7 @@ class ApitallyPlugin(InitPluginProtocol):
243
251
  "size": response_size,
244
252
  "body": response_body,
245
253
  },
254
+ exception=request.state["exception"] if "exception" in request.state else None,
246
255
  )
247
256
 
248
257
  def get_path(self, request: Request) -> Optional[str]:
apitally/starlette.py CHANGED
@@ -20,6 +20,7 @@ from apitally.client.consumers import Consumer as ApitallyConsumer
20
20
  from apitally.client.request_logging import (
21
21
  BODY_TOO_LARGE,
22
22
  MAX_BODY_SIZE,
23
+ RequestLogger,
23
24
  RequestLoggingConfig,
24
25
  )
25
26
  from apitally.common import get_versions, parse_int
@@ -73,7 +74,7 @@ class ApitallyMiddleware:
73
74
  self.client.set_startup_data(data)
74
75
 
75
76
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
76
- if scope["type"] == "http" and scope["method"] != "OPTIONS":
77
+ if self.client.enabled and scope["type"] == "http" and scope["method"] != "OPTIONS":
77
78
  timestamp = time.time()
78
79
  request = Request(scope)
79
80
  request_size = parse_int(request.headers.get("Content-Length"))
@@ -86,6 +87,7 @@ class ApitallyMiddleware:
86
87
  response_body_too_large = False
87
88
  response_size: Optional[int] = None
88
89
  response_chunked = False
90
+ response_content_type: Optional[str] = None
89
91
  exception: Optional[BaseException] = None
90
92
  start_time = time.perf_counter()
91
93
 
@@ -107,6 +109,7 @@ class ApitallyMiddleware:
107
109
  response_body, \
108
110
  response_body_too_large, \
109
111
  response_chunked, \
112
+ response_content_type, \
110
113
  response_size
111
114
  if message["type"] == "http.response.start":
112
115
  response_time = time.perf_counter() - start_time
@@ -116,12 +119,17 @@ class ApitallyMiddleware:
116
119
  response_headers.get("Transfer-Encoding") == "chunked"
117
120
  or "Content-Length" not in response_headers
118
121
  )
122
+ response_content_type = response_headers.get("Content-Type")
119
123
  response_size = parse_int(response_headers.get("Content-Length")) if not response_chunked else 0
120
124
  response_body_too_large = response_size is not None and response_size > MAX_BODY_SIZE
121
125
  elif message["type"] == "http.response.body":
122
126
  if response_chunked and response_size is not None:
123
127
  response_size += len(message.get("body", b""))
124
- if (self.capture_response_body or response_status == 422) and not response_body_too_large:
128
+ if (
129
+ (self.capture_response_body or response_status == 422)
130
+ and RequestLogger.is_supported_content_type(response_content_type)
131
+ and not response_body_too_large
132
+ ):
125
133
  response_body += message.get("body", b"")
126
134
  if len(response_body) > MAX_BODY_SIZE:
127
135
  response_body_too_large = True
@@ -220,6 +228,7 @@ class ApitallyMiddleware:
220
228
  "size": response_size,
221
229
  "body": response_body,
222
230
  },
231
+ exception=exception,
223
232
  )
224
233
 
225
234
  def get_path(self, request: Request, routes: Optional[list[BaseRoute]] = None) -> Optional[str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apitally
3
- Version: 0.14.5
3
+ Version: 0.15.0
4
4
  Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
5
5
  Project-URL: Homepage, https://apitally.io
6
6
  Project-URL: Documentation, https://docs.apitally.io
@@ -62,37 +62,37 @@ Requires-Dist: starlette<1.0.0,>=0.21.0; extra == 'starlette'
62
62
  Description-Content-Type: text/markdown
63
63
 
64
64
  <p align="center">
65
- <picture>
66
- <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-vertical-dark.png">
67
- <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-vertical-light.png">
68
- <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-vertical-light.png" width="150">
69
- </picture>
65
+ <a href="https://apitally.io" target="_blank">
66
+ <picture>
67
+ <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-vertical-dark.png">
68
+ <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-vertical-light.png">
69
+ <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-vertical-light.png" width="150">
70
+ </picture>
71
+ </a>
70
72
  </p>
71
73
 
72
- <p align="center"><b>Analytics, logging & monitoring for REST APIs.</b></p>
74
+ <p align="center"><b>Simple, privacy-focused API monitoring & analytics</b></p>
73
75
 
74
- <p align="center"><i>Apitally helps you understand how your APIs are being used and alerts you when things go wrong.<br>It's super easy to use and designed to protect your data privacy.</i></p>
75
-
76
- <p align="center">🔗 <b><a href="https://apitally.io" target="_blank">apitally.io</a></b></p>
76
+ <p align="center"><i>Apitally helps you understand how your APIs are being used and alerts you when things go wrong.<br>Just add two lines of code to your project to get started.</i></p>
77
+ <br>
77
78
 
78
79
  ![Apitally screenshots](https://assets.apitally.io/screenshots/overview.png)
79
80
 
80
81
  ---
81
82
 
82
- # Apitally client library for Python
83
+ # Apitally SDK for Python
83
84
 
84
85
  [![Tests](https://github.com/apitally/apitally-py/actions/workflows/tests.yaml/badge.svg?event=push)](https://github.com/apitally/apitally-py/actions)
85
86
  [![Codecov](https://codecov.io/gh/apitally/apitally-py/graph/badge.svg?token=UNLYBY4Y3V)](https://codecov.io/gh/apitally/apitally-py)
86
87
  [![PyPI](https://img.shields.io/pypi/v/apitally?logo=pypi&logoColor=white&color=%23006dad)](https://pypi.org/project/apitally/)
87
88
 
88
- This client library for Apitally currently supports the following Python web
89
- frameworks:
89
+ This SDK for Apitally currently supports the following Python web frameworks:
90
90
 
91
91
  - [FastAPI](https://docs.apitally.io/frameworks/fastapi)
92
- - [Starlette](https://docs.apitally.io/frameworks/starlette)
93
- - [Flask](https://docs.apitally.io/frameworks/flask)
94
- - [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
95
92
  - [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
93
+ - [Django Ninja](https://docs.apitally.io/frameworks/django-ninja)
94
+ - [Flask](https://docs.apitally.io/frameworks/flask)
95
+ - [Starlette](https://docs.apitally.io/frameworks/starlette)
96
96
  - [Litestar](https://docs.apitally.io/frameworks/litestar)
97
97
 
98
98
  Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
@@ -100,10 +100,29 @@ the 📚 [documentation](https://docs.apitally.io).
100
100
 
101
101
  ## Key features
102
102
 
103
- - Middleware for different frameworks to capture metadata about API endpoints,
104
- requests and responses
105
- - Non-blocking clients that aggregate and send captured data to Apitally in
106
- regular intervals
103
+ ### API analytics
104
+
105
+ Track traffic, error and performance metrics for your API, each endpoint and
106
+ individual API consumers, allowing you to make informed, data-driven engineering
107
+ and product decisions.
108
+
109
+ ### Error tracking
110
+
111
+ Understand which validation rules in your endpoints cause client errors. Capture
112
+ error details and stack traces for 500 error responses, and have them linked to
113
+ Sentry issues automatically.
114
+
115
+ ### Request logging
116
+
117
+ Drill down from insights to individual requests or use powerful filtering to
118
+ understand how consumers have interacted with your API. Configure exactly what
119
+ is included in the logs to meet your requirements.
120
+
121
+ ### API monitoring & alerting
122
+
123
+ Get notified immediately if something isn't right using custom alerts, synthetic
124
+ uptime checks and heartbeat monitoring. Notifications can be delivered via
125
+ email, Slack or Microsoft Teams.
107
126
 
108
127
  ## Install
109
128
 
@@ -140,6 +159,25 @@ app.add_middleware(
140
159
  )
141
160
  ```
142
161
 
162
+ ### Django
163
+
164
+ This is an example of how to add the Apitally middleware to a Django Ninja or
165
+ Django REST Framework application. For further instructions, see our
166
+ [setup guide for Django](https://docs.apitally.io/frameworks/django).
167
+
168
+ In your Django `settings.py` file:
169
+
170
+ ```python
171
+ MIDDLEWARE = [
172
+ "apitally.django.ApitallyMiddleware",
173
+ # Other middleware ...
174
+ ]
175
+ APITALLY_MIDDLEWARE = {
176
+ "client_id": "your-client-id",
177
+ "env": "dev", # or "prod" etc.
178
+ }
179
+ ```
180
+
143
181
  ### Flask
144
182
 
145
183
  This is an example of how to add the Apitally middleware to a Flask application.
@@ -158,23 +196,22 @@ app.wsgi_app = ApitallyMiddleware(
158
196
  )
159
197
  ```
160
198
 
161
- ### Django
162
-
163
- This is an example of how to add the Apitally middleware to a Django Ninja or
164
- Django REST Framework application. For further instructions, see our
165
- [setup guide for Django](https://docs.apitally.io/frameworks/django).
199
+ ### Starlette
166
200
 
167
- In your Django `settings.py` file:
201
+ This is an example of how to add the Apitally middleware to a Starlette
202
+ application. For further instructions, see our
203
+ [setup guide for Starlette](https://docs.apitally.io/frameworks/starlette).
168
204
 
169
205
  ```python
170
- MIDDLEWARE = [
171
- "apitally.django.ApitallyMiddleware",
172
- # Other middleware ...
173
- ]
174
- APITALLY_MIDDLEWARE = {
175
- "client_id": "your-client-id",
176
- "env": "dev", # or "prod" etc.
177
- }
206
+ from starlette.applications import Starlette
207
+ from apitally.starlette import ApitallyMiddleware
208
+
209
+ app = Starlette(routes=[...])
210
+ app.add_middleware(
211
+ ApitallyMiddleware,
212
+ client_id="your-client-id",
213
+ env="dev", # or "prod" etc.
214
+ )
178
215
  ```
179
216
 
180
217
  ### Litestar
@@ -0,0 +1,25 @@
1
+ apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
2
+ apitally/common.py,sha256=Y8MRuTUHFUeQkcDrCLUxnqIPRpYIiW8S43T0QUab-_A,1267
3
+ apitally/django.py,sha256=1gLW5aVbIobjHoa5OzC3K1E85mC2a00PpJIAR9ATko4,16937
4
+ apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
5
+ apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
6
+ apitally/fastapi.py,sha256=IfKfgsmIY8_AtnuMTW2sW4qnkya61CAE2vBoIpcc9tk,169
7
+ apitally/flask.py,sha256=p_u33_FQq2i5AebWB8wYxXX0CPhcX8OJHGWj5dR4sPY,9622
8
+ apitally/litestar.py,sha256=HThNH-gAnFtLyVU4Eh8L_obd0f3TNLYoTZ8IGgz1ZKE,13610
9
+ apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ apitally/starlette.py,sha256=liK1KSEiQolHm1cNfJm6tkmE3uujoVqj8b-2prUVa3o,13277
11
+ apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ apitally/client/client_asyncio.py,sha256=9mdi9Hmb6-xn7dNdwP84e4PNAHGg2bYdMEgIfPUAtcQ,7003
13
+ apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbgw,3799
14
+ apitally/client/client_threading.py,sha256=7JPu2Uulev7X2RiSLx4HJYfvAP6Z5zB_yuSevMfQC7I,7389
15
+ apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
16
+ apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
17
+ apitally/client/request_logging.py,sha256=OL1jlpHXYpZw2VKRekgNSwZQ0qZJeiiJxKeTYrmP22g,13913
18
+ apitally/client/requests.py,sha256=RdJyvIqQGVHvS-wjpAPUwcO7byOJ6jO8dYqNTU2Furg,3685
19
+ apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
20
+ apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
21
+ apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
22
+ apitally-0.15.0.dist-info/METADATA,sha256=CZxfxtM3p1RBy5ICdnuhjAWt4CESHhKtAIFEFtKCbjw,8643
23
+ apitally-0.15.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
+ apitally-0.15.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
25
+ apitally-0.15.0.dist-info/RECORD,,
@@ -1,24 +0,0 @@
1
- apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
2
- apitally/common.py,sha256=Y8MRuTUHFUeQkcDrCLUxnqIPRpYIiW8S43T0QUab-_A,1267
3
- apitally/django.py,sha256=xM8zyH8LYr4BxAlhvfpUyau4OF5i_TMcISs3eJ7xvpY,16621
4
- apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
5
- apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
6
- apitally/fastapi.py,sha256=IfKfgsmIY8_AtnuMTW2sW4qnkya61CAE2vBoIpcc9tk,169
7
- apitally/flask.py,sha256=Th5LsMsTKkWERPrKfSWPhzrp99tg0pDtKXgtlVLx3eo,9279
8
- apitally/litestar.py,sha256=hAH2-OVVXBDVY8LopfIGv30yYwi-71tSEsKd6648CYc,13098
9
- apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- apitally/starlette.py,sha256=VaT4-QVSYC0YX1U5kVI-dGROEd64IbjYU5lx5N16yf8,12852
11
- apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- apitally/client/client_asyncio.py,sha256=2RibaadLAEdl2i0yAb4dSEDq_r46w6HPFpZVzvt59aQ,6941
13
- apitally/client/client_base.py,sha256=dXsaB7scd0wCd_DcdiUvNlDLZ2pWbWsGDnJ4fLYvJGg,3771
14
- apitally/client/client_threading.py,sha256=Y8LlA_8LFHAuXym-T4bY_XFqXxiIDw9qFqO6K3FbZy0,7356
15
- apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
16
- apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
17
- apitally/client/request_logging.py,sha256=kKEPfNVK-T-maz4pkeyu805rZ4B2MFsjWjyu0PeW1WE,12973
18
- apitally/client/requests.py,sha256=RdJyvIqQGVHvS-wjpAPUwcO7byOJ6jO8dYqNTU2Furg,3685
19
- apitally/client/server_errors.py,sha256=axEhOxqV5SWjk0QCZTLVv2UMIaTfqPc81Typ4DXt66A,4646
20
- apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
21
- apitally-0.14.5.dist-info/METADATA,sha256=YlHcMcEDL-0mhhfXhQPS1PbbsobvZJhn8Ie8jiPfUpA,7570
22
- apitally-0.14.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- apitally-0.14.5.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
24
- apitally-0.14.5.dist-info/RECORD,,