apitally 0.18.1__py3-none-any.whl → 0.19.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.
@@ -5,6 +5,7 @@ import tempfile
5
5
  import threading
6
6
  import time
7
7
  from collections import deque
8
+ from contextlib import suppress
8
9
  from dataclasses import dataclass, field
9
10
  from functools import lru_cache
10
11
  from io import BufferedReader
@@ -22,6 +23,12 @@ from apitally.client.server_errors import (
22
23
  )
23
24
 
24
25
 
26
+ try:
27
+ from typing import NotRequired
28
+ except ImportError:
29
+ from typing_extensions import NotRequired
30
+
31
+
25
32
  logger = get_logger(__name__)
26
33
 
27
34
  MAX_BODY_SIZE = 50_000 # 50 KB (uncompressed)
@@ -31,7 +38,12 @@ MAX_FILES_IN_DEQUE = 50
31
38
  BODY_TOO_LARGE = b"<body too large>"
32
39
  BODY_MASKED = b"<masked>"
33
40
  MASKED = "******"
34
- ALLOWED_CONTENT_TYPES = ["application/json", "text/plain"]
41
+ ALLOWED_CONTENT_TYPES = [
42
+ "application/json",
43
+ "application/problem+json",
44
+ "application/vnd.api+json",
45
+ "text/plain",
46
+ ]
35
47
  EXCLUDE_PATH_PATTERNS = [
36
48
  r"/_?healthz?$",
37
49
  r"/_?health[\-_]?checks?$",
@@ -61,6 +73,16 @@ MASK_HEADER_PATTERNS = [
61
73
  r"token",
62
74
  r"cookie",
63
75
  ]
76
+ MASK_BODY_FIELD_PATTERNS = [
77
+ r"password",
78
+ r"pwd",
79
+ r"token",
80
+ r"secret",
81
+ r"auth",
82
+ r"card[\-_ ]number",
83
+ r"ccv",
84
+ r"ssn",
85
+ ]
64
86
 
65
87
 
66
88
  class RequestDict(TypedDict):
@@ -82,6 +104,20 @@ class ResponseDict(TypedDict):
82
104
  body: Optional[bytes]
83
105
 
84
106
 
107
+ class ExceptionDict(TypedDict):
108
+ type: str
109
+ message: str
110
+ traceback: str
111
+ sentry_event_id: NotRequired[str]
112
+
113
+
114
+ class RequestLogItem(TypedDict):
115
+ uuid: str
116
+ request: RequestDict
117
+ response: ResponseDict
118
+ exception: NotRequired[ExceptionDict]
119
+
120
+
85
121
  @dataclass
86
122
  class RequestLoggingConfig:
87
123
  """
@@ -97,8 +133,9 @@ class RequestLoggingConfig:
97
133
  log_exception: Whether to log unhandled exceptions in case of server errors
98
134
  mask_query_params: Query parameter names to mask in logs. Expects regular expressions.
99
135
  mask_headers: Header names to mask in logs. Expects regular expressions.
100
- mask_request_body_callback: Callback to mask the request body. Expects (method, path, body) and returns the masked body as bytes or None.
101
- mask_response_body_callback: Callback to mask the response body. Expects (method, path, body) and returns the masked body as bytes or None.
136
+ mask_body_fields: Body fields to mask in logs. Expects regular expressions.
137
+ mask_request_body_callback: Callback to mask the request body. Expects `request: RequestDict` as argument and returns the masked body as bytes or None.
138
+ mask_response_body_callback: Callback to mask the response body. Expects `request: RequestDict` and `response: ResponseDict` as arguments and returns the masked body as bytes or None.
102
139
  exclude_paths: Paths to exclude from logging. Expects regular expressions.
103
140
  exclude_callback: Callback to exclude requests from logging. Should expect two arguments, `request: RequestDict` and `response: ResponseDict`, and return True to exclude the request.
104
141
  """
@@ -112,6 +149,7 @@ class RequestLoggingConfig:
112
149
  log_exception: bool = True
113
150
  mask_query_params: List[str] = field(default_factory=list)
114
151
  mask_headers: List[str] = field(default_factory=list)
152
+ mask_body_fields: List[str] = field(default_factory=list)
115
153
  mask_request_body_callback: Optional[Callable[[RequestDict], Optional[bytes]]] = None
116
154
  mask_response_body_callback: Optional[Callable[[RequestDict, ResponseDict], Optional[bytes]]] = None
117
155
  exclude_paths: List[str] = field(default_factory=list)
@@ -161,7 +199,8 @@ class RequestLogger:
161
199
  self.config = config or RequestLoggingConfig()
162
200
  self.enabled = self.config.enabled and _check_writable_fs()
163
201
  self.serialize = _get_json_serializer()
164
- self.write_deque: deque[Dict[str, Any]] = deque([], MAX_REQUESTS_IN_DEQUE)
202
+ self.deserialize = _get_json_deserializer()
203
+ self.write_deque: deque[RequestLogItem] = deque([], MAX_REQUESTS_IN_DEQUE)
165
204
  self.file_deque: deque[TempGzipFile] = deque([])
166
205
  self.file: Optional[TempGzipFile] = None
167
206
  self.lock = threading.Lock()
@@ -172,10 +211,14 @@ class RequestLogger:
172
211
  return self.file.size if self.file is not None else 0
173
212
 
174
213
  def log_request(
175
- self, request: RequestDict, response: ResponseDict, exception: Optional[BaseException] = None
214
+ self,
215
+ request: RequestDict,
216
+ response: ResponseDict,
217
+ exception: Optional[BaseException] = None,
176
218
  ) -> None:
177
219
  if not self.enabled or self.suspend_until is not None:
178
220
  return
221
+
179
222
  parsed_url = urlparse(request["url"])
180
223
  user_agent = self._get_user_agent(request["headers"])
181
224
  if (
@@ -185,55 +228,20 @@ class RequestLogger:
185
228
  ):
186
229
  return
187
230
 
188
- query = self._mask_query_params(parsed_url.query) if self.config.log_query_params else ""
189
- request["url"] = urlunparse(parsed_url._replace(query=query))
190
-
191
231
  if not self.config.log_request_body or not self._has_supported_content_type(request["headers"]):
192
232
  request["body"] = None
193
- elif (
194
- self.config.mask_request_body_callback is not None
195
- and request["body"] is not None
196
- and request["body"] != BODY_TOO_LARGE
197
- ):
198
- try:
199
- request["body"] = self.config.mask_request_body_callback(request)
200
- except Exception: # pragma: no cover
201
- logger.exception("User-provided mask_request_body_callback function raised an exception")
202
- request["body"] = None
203
- if request["body"] is None:
204
- request["body"] = BODY_MASKED
205
- if request["body"] is not None and len(request["body"]) > MAX_BODY_SIZE:
206
- request["body"] = BODY_TOO_LARGE
207
-
208
233
  if not self.config.log_response_body or not self._has_supported_content_type(response["headers"]):
209
234
  response["body"] = None
210
- elif (
211
- self.config.mask_response_body_callback is not None
212
- and response["body"] is not None
213
- and response["body"] != BODY_TOO_LARGE
214
- ):
215
- try:
216
- response["body"] = self.config.mask_response_body_callback(request, response)
217
- except Exception: # pragma: no cover
218
- logger.exception("User-provided mask_response_body_callback function raised an exception")
219
- response["body"] = None
220
- if response["body"] is None:
221
- response["body"] = BODY_MASKED
222
- if response["body"] is not None and len(response["body"]) > MAX_BODY_SIZE:
223
- response["body"] = BODY_TOO_LARGE
224
-
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
235
 
228
236
  if request["size"] is not None and request["size"] < 0:
229
237
  request["size"] = None
230
238
  if response["size"] is not None and response["size"] < 0:
231
239
  response["size"] = None
232
240
 
233
- item: Dict[str, Any] = {
241
+ item: RequestLogItem = {
234
242
  "uuid": str(uuid4()),
235
- "request": _skip_empty_values(request),
236
- "response": _skip_empty_values(response),
243
+ "request": request,
244
+ "response": response,
237
245
  }
238
246
  if exception is not None and self.config.log_exception:
239
247
  item["exception"] = {
@@ -242,6 +250,7 @@ class RequestLogger:
242
250
  "traceback": get_truncated_exception_traceback(exception),
243
251
  }
244
252
  get_sentry_event_id_async(lambda event_id: item["exception"].update({"sentry_event_id": event_id}))
253
+
245
254
  self.write_deque.append(item)
246
255
 
247
256
  def write_to_file(self) -> None:
@@ -253,6 +262,9 @@ class RequestLogger:
253
262
  while True:
254
263
  try:
255
264
  item = self.write_deque.popleft()
265
+ item = self._apply_masking(item)
266
+ item["request"] = _skip_empty_values(item["request"]) # type: ignore[typeddict-item]
267
+ item["response"] = _skip_empty_values(item["response"]) # type: ignore[typeddict-item]
256
268
  self.file.write_line(self.serialize(item))
257
269
  except IndexError:
258
270
  break
@@ -307,6 +319,67 @@ class RequestLogger:
307
319
  def _should_exclude_user_agent(self, user_agent: Optional[str]) -> bool:
308
320
  return self._match_patterns(user_agent, EXCLUDE_USER_AGENT_PATTERNS) if user_agent is not None else False
309
321
 
322
+ def _apply_masking(self, data: RequestLogItem) -> RequestLogItem:
323
+ # Apply user-provided mask_request_body_callback function
324
+ if (
325
+ self.config.mask_request_body_callback is not None
326
+ and data["request"]["body"] is not None
327
+ and data["request"]["body"] != BODY_TOO_LARGE
328
+ ):
329
+ try:
330
+ data["request"]["body"] = self.config.mask_request_body_callback(data["request"])
331
+ except Exception: # pragma: no cover
332
+ logger.exception("User-provided mask_request_body_callback function raised an exception")
333
+ data["request"]["body"] = None
334
+ if data["request"]["body"] is None:
335
+ data["request"]["body"] = BODY_MASKED
336
+
337
+ # Apply user-provided mask_response_body_callback function
338
+ if (
339
+ self.config.mask_response_body_callback is not None
340
+ and data["response"]["body"] is not None
341
+ and data["response"]["body"] != BODY_TOO_LARGE
342
+ ):
343
+ try:
344
+ data["response"]["body"] = self.config.mask_response_body_callback(data["request"], data["response"])
345
+ except Exception: # pragma: no cover
346
+ logger.exception("User-provided mask_response_body_callback function raised an exception")
347
+ data["response"]["body"] = None
348
+ if data["response"]["body"] is None:
349
+ data["response"]["body"] = BODY_MASKED
350
+
351
+ # Check request and response body sizes
352
+ if data["request"]["body"] is not None and len(data["request"]["body"]) > MAX_BODY_SIZE:
353
+ data["request"]["body"] = BODY_TOO_LARGE
354
+ if data["response"]["body"] is not None and len(data["response"]["body"]) > MAX_BODY_SIZE:
355
+ data["response"]["body"] = BODY_TOO_LARGE
356
+
357
+ # Mask request and response body fields
358
+ for key in ("request", "response"):
359
+ if data[key]["body"] is None or data[key]["body"] == BODY_TOO_LARGE or data[key]["body"] == BODY_MASKED:
360
+ continue
361
+ body = data[key]["body"]
362
+ body_is_json = self._has_json_content_type(data[key]["headers"])
363
+ if body is not None and (body_is_json is None or body_is_json):
364
+ with suppress(Exception):
365
+ masked_body = self._mask_body(self.deserialize(body))
366
+ data[key]["body"] = self.serialize(masked_body)
367
+
368
+ # Mask request and response headers
369
+ data["request"]["headers"] = (
370
+ self._mask_headers(data["request"]["headers"]) if self.config.log_request_headers else []
371
+ )
372
+ data["response"]["headers"] = (
373
+ self._mask_headers(data["response"]["headers"]) if self.config.log_response_headers else []
374
+ )
375
+
376
+ # Mask query params
377
+ parsed_url = urlparse(data["request"]["url"])
378
+ query = self._mask_query_params(parsed_url.query) if self.config.log_query_params else ""
379
+ data["request"]["url"] = urlunparse(parsed_url._replace(query=query))
380
+
381
+ return data
382
+
310
383
  def _mask_query_params(self, query: str) -> str:
311
384
  query_params = parse_qsl(query)
312
385
  masked_query_params = [(k, v if not self._should_mask_query_param(k) else MASKED) for k, v in query_params]
@@ -315,6 +388,16 @@ class RequestLogger:
315
388
  def _mask_headers(self, headers: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
316
389
  return [(k, v if not self._should_mask_header(k) else MASKED) for k, v in headers]
317
390
 
391
+ def _mask_body(self, data: Any) -> Any:
392
+ if isinstance(data, dict):
393
+ return {
394
+ k: (MASKED if isinstance(v, str) and self._should_mask_body_field(k) else self._mask_body(v))
395
+ for k, v in data.items()
396
+ }
397
+ if isinstance(data, list):
398
+ return [self._mask_body(item) for item in data]
399
+ return data
400
+
318
401
  @lru_cache(maxsize=100)
319
402
  def _should_mask_query_param(self, query_param_name: str) -> bool:
320
403
  patterns = self.config.mask_query_params + MASK_QUERY_PARAM_PATTERNS
@@ -325,6 +408,11 @@ class RequestLogger:
325
408
  patterns = self.config.mask_headers + MASK_HEADER_PATTERNS
326
409
  return self._match_patterns(header_name, patterns)
327
410
 
411
+ @lru_cache(maxsize=100)
412
+ def _should_mask_body_field(self, field_name: str) -> bool:
413
+ patterns = self.config.mask_body_fields + MASK_BODY_FIELD_PATTERNS
414
+ return self._match_patterns(field_name, patterns)
415
+
328
416
  @staticmethod
329
417
  def _match_patterns(value: str, patterns: List[str]) -> bool:
330
418
  for pattern in patterns:
@@ -338,12 +426,17 @@ class RequestLogger:
338
426
 
339
427
  @staticmethod
340
428
  def _has_supported_content_type(headers: List[Tuple[str, str]]) -> bool:
341
- content_type = next((v for k, v in headers if k.lower() == "content-type"), None)
429
+ content_type = next((v.lower() for k, v in headers if k.lower() == "content-type"), None)
342
430
  return RequestLogger.is_supported_content_type(content_type)
343
431
 
432
+ @staticmethod
433
+ def _has_json_content_type(headers: List[Tuple[str, str]]) -> Optional[bool]:
434
+ content_type = next((v.lower() for k, v in headers if k.lower() == "content-type"), None)
435
+ return None if content_type is None else (re.search(r"\bjson\b", content_type) is not None)
436
+
344
437
  @staticmethod
345
438
  def is_supported_content_type(content_type: Optional[str]) -> bool:
346
- return content_type is not None and any(content_type.startswith(t) for t in ALLOWED_CONTENT_TYPES)
439
+ return content_type is not None and any(content_type.lower().startswith(t) for t in ALLOWED_CONTENT_TYPES)
347
440
 
348
441
 
349
442
  def _check_writable_fs() -> bool:
@@ -377,6 +470,17 @@ def _get_json_serializer() -> Callable[[Any], bytes]:
377
470
  return json_dumps
378
471
 
379
472
 
473
+ def _get_json_deserializer() -> Callable[[bytes], Any]:
474
+ try:
475
+ import orjson # type: ignore
476
+
477
+ return orjson.loads
478
+ except ImportError:
479
+ import json
480
+
481
+ return json.loads
482
+
483
+
380
484
  def _skip_empty_values(data: Mapping) -> Dict:
381
485
  return {
382
486
  k: v for k, v in data.items() if v is not None and not (isinstance(v, (list, dict, bytes, str)) and len(v) == 0)
apitally/django.py CHANGED
@@ -123,115 +123,114 @@ class ApitallyMiddleware:
123
123
  )
124
124
 
125
125
  def __call__(self, request: HttpRequest) -> HttpResponse:
126
- if self.client.enabled and request.method is not None and request.method != "OPTIONS":
127
- timestamp = time.time()
128
-
129
- request_size = parse_int(request.headers.get("Content-Length"))
130
- request_body = b""
131
- if self.capture_request_body:
132
- request_body = (
133
- request.body
134
- if request_size is not None and request_size <= MAX_BODY_SIZE and len(request.body) <= MAX_BODY_SIZE
135
- else BODY_TOO_LARGE
136
- )
126
+ if not self.client.enabled or request.method is None or request.method == "OPTIONS":
127
+ return self.get_response(request)
128
+
129
+ timestamp = time.time()
130
+ request_size = parse_int(request.headers.get("Content-Length"))
131
+ request_body = b""
132
+ if self.capture_request_body:
133
+ request_body = (
134
+ request.body
135
+ if request_size is not None and request_size <= MAX_BODY_SIZE and len(request.body) <= MAX_BODY_SIZE
136
+ else BODY_TOO_LARGE
137
+ )
137
138
 
138
- start_time = time.perf_counter()
139
- response = self.get_response(request)
140
- response_time = time.perf_counter() - start_time
141
- path = self.get_path(request)
142
-
143
- if path is not None:
144
- try:
145
- consumer = self.get_consumer(request)
146
- consumer_identifier = consumer.identifier if consumer else None
147
- self.client.consumer_registry.add_or_update_consumer(consumer)
148
- except Exception: # pragma: no cover
149
- logger.exception("Failed to get consumer for request")
150
- consumer_identifier = None
151
-
152
- response_size = (
153
- parse_int(response["Content-Length"])
154
- if response.has_header("Content-Length")
155
- else (len(response.content) if not response.streaming else None)
156
- )
157
- response_body = b""
158
- response_content_type = response.get("Content-Type")
159
- if (
160
- self.capture_response_body
161
- and not response.streaming
162
- and RequestLogger.is_supported_content_type(response_content_type)
163
- ):
164
- response_body = (
165
- response.content
166
- if response_size is not None and response_size <= MAX_BODY_SIZE
167
- else BODY_TOO_LARGE
168
- )
139
+ start_time = time.perf_counter()
140
+ response = self.get_response(request)
141
+ response_time = time.perf_counter() - start_time
142
+ path = self.get_path(request)
143
+
144
+ if path is None:
145
+ return response
146
+
147
+ try:
148
+ consumer = self.get_consumer(request)
149
+ consumer_identifier = consumer.identifier if consumer else None
150
+ self.client.consumer_registry.add_or_update_consumer(consumer)
151
+ except Exception: # pragma: no cover
152
+ logger.exception("Failed to get consumer for request")
153
+ consumer_identifier = None
154
+
155
+ response_size = (
156
+ parse_int(response["Content-Length"])
157
+ if response.has_header("Content-Length")
158
+ else (len(response.content) if not response.streaming else None)
159
+ )
160
+ response_body = b""
161
+ response_content_type = response.get("Content-Type")
162
+ if (
163
+ self.capture_response_body
164
+ and not response.streaming
165
+ and RequestLogger.is_supported_content_type(response_content_type)
166
+ ):
167
+ response_body = (
168
+ response.content if response_size is not None and response_size <= MAX_BODY_SIZE else BODY_TOO_LARGE
169
+ )
169
170
 
170
- try:
171
- self.client.request_counter.add_request(
171
+ try:
172
+ self.client.request_counter.add_request(
173
+ consumer=consumer_identifier,
174
+ method=request.method,
175
+ path=path,
176
+ status_code=response.status_code,
177
+ response_time=response_time,
178
+ request_size=request_size,
179
+ response_size=response_size,
180
+ )
181
+ except Exception: # pragma: no cover
182
+ logger.exception("Failed to log request metadata")
183
+
184
+ if (
185
+ response.status_code == 422
186
+ and (content_type := response.get("Content-Type")) is not None
187
+ and content_type.startswith("application/json")
188
+ ):
189
+ try:
190
+ body = try_json_loads(response.content, encoding=response.get("Content-Encoding"))
191
+ if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
192
+ # Log Django Ninja / Pydantic validation errors
193
+ self.client.validation_error_counter.add_validation_errors(
172
194
  consumer=consumer_identifier,
173
195
  method=request.method,
174
196
  path=path,
175
- status_code=response.status_code,
176
- response_time=response_time,
177
- request_size=request_size,
178
- response_size=response_size,
197
+ detail=body["detail"],
179
198
  )
180
- except Exception: # pragma: no cover
181
- logger.exception("Failed to log request metadata")
182
-
183
- if (
184
- response.status_code == 422
185
- and (content_type := response.get("Content-Type")) is not None
186
- and content_type.startswith("application/json")
187
- ):
188
- try:
189
- body = try_json_loads(response.content, encoding=response.get("Content-Encoding"))
190
- if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
191
- # Log Django Ninja / Pydantic validation errors
192
- self.client.validation_error_counter.add_validation_errors(
193
- consumer=consumer_identifier,
194
- method=request.method,
195
- path=path,
196
- detail=body["detail"],
197
- )
198
- except Exception: # pragma: no cover
199
- logger.exception("Failed to log validation errors")
200
-
201
- if response.status_code == 500 and hasattr(request, "unhandled_exception"):
202
- try:
203
- self.client.server_error_counter.add_server_error(
204
- consumer=consumer_identifier,
205
- method=request.method,
206
- path=path,
207
- exception=getattr(request, "unhandled_exception"),
208
- )
209
- except Exception: # pragma: no cover
210
- logger.exception("Failed to log server error")
211
-
212
- if self.client.request_logger.enabled:
213
- self.client.request_logger.log_request(
214
- request={
215
- "timestamp": timestamp,
216
- "method": request.method,
217
- "path": path,
218
- "url": request.build_absolute_uri(),
219
- "headers": list(request.headers.items()),
220
- "size": request_size,
221
- "consumer": consumer_identifier,
222
- "body": request_body,
223
- },
224
- response={
225
- "status_code": response.status_code,
226
- "response_time": response_time,
227
- "headers": list(response.items()),
228
- "size": response_size,
229
- "body": response_body,
230
- },
231
- exception=getattr(request, "unhandled_exception", None),
232
- )
233
- else:
234
- response = self.get_response(request)
199
+ except Exception: # pragma: no cover
200
+ logger.exception("Failed to log validation errors")
201
+
202
+ if response.status_code == 500 and hasattr(request, "unhandled_exception"):
203
+ try:
204
+ self.client.server_error_counter.add_server_error(
205
+ consumer=consumer_identifier,
206
+ method=request.method,
207
+ path=path,
208
+ exception=getattr(request, "unhandled_exception"),
209
+ )
210
+ except Exception: # pragma: no cover
211
+ logger.exception("Failed to log server error")
212
+
213
+ if self.client.request_logger.enabled:
214
+ self.client.request_logger.log_request(
215
+ request={
216
+ "timestamp": timestamp,
217
+ "method": request.method,
218
+ "path": path,
219
+ "url": request.build_absolute_uri(),
220
+ "headers": list(request.headers.items()),
221
+ "size": request_size,
222
+ "consumer": consumer_identifier,
223
+ "body": request_body,
224
+ },
225
+ response={
226
+ "status_code": response.status_code,
227
+ "response_time": response_time,
228
+ "headers": list(response.items()),
229
+ "size": response_size,
230
+ "body": response_body,
231
+ },
232
+ exception=getattr(request, "unhandled_exception", None),
233
+ )
235
234
 
236
235
  return response
237
236
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apitally
3
- Version: 0.18.1
3
+ Version: 0.19.0
4
4
  Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette, Litestar and BlackSheep.
5
5
  Project-URL: Homepage, https://apitally.io
6
6
  Project-URL: Documentation, https://docs.apitally.io
@@ -32,6 +32,7 @@ Classifier: Topic :: System :: Monitoring
32
32
  Classifier: Typing :: Typed
33
33
  Requires-Python: <4.0,>=3.9
34
34
  Requires-Dist: backoff>=2.0.0
35
+ Requires-Dist: typing-extensions>=4.0.0; python_version < '3.11'
35
36
  Provides-Extra: blacksheep
36
37
  Requires-Dist: blacksheep>=2; extra == 'blacksheep'
37
38
  Requires-Dist: httpx>=0.22.0; extra == 'blacksheep'
@@ -67,21 +68,17 @@ Description-Content-Type: text/markdown
67
68
  <p align="center">
68
69
  <a href="https://apitally.io" target="_blank">
69
70
  <picture>
70
- <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-vertical-dark.png">
71
- <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-vertical-light.png">
72
- <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-vertical-light.png" width="150">
71
+ <source media="(prefers-color-scheme: dark)" srcset="https://assets.apitally.io/logos/logo-horizontal-new-dark.png">
72
+ <source media="(prefers-color-scheme: light)" srcset="https://assets.apitally.io/logos/logo-horizontal-new-light.png">
73
+ <img alt="Apitally logo" src="https://assets.apitally.io/logos/logo-horizontal-new-light.png" width="220">
73
74
  </picture>
74
75
  </a>
75
76
  </p>
76
-
77
- <p align="center"><b>Simple, privacy-focused API monitoring & analytics</b></p>
78
-
79
- <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
+ <p align="center"><b>API monitoring & analytics made simple</b></p>
78
+ <p align="center" style="color: #ccc;">Real-time metrics, request logs, and alerts for your APIs — with just a few lines of code.</p>
79
+ <br>
80
+ <img alt="Apitally screenshots" src="https://assets.apitally.io/screenshots/overview.png">
80
81
  <br>
81
-
82
- ![Apitally screenshots](https://assets.apitally.io/screenshots/overview.png)
83
-
84
- ---
85
82
 
86
83
  # Apitally SDK for Python
87
84
 
@@ -138,7 +135,7 @@ pip install apitally[fastapi]
138
135
  ```
139
136
 
140
137
  The available extras are: `fastapi`, `flask`, `django_rest_framework`,
141
- `django_ninja`, `starlette` and `litestar`.
138
+ `django_ninja`, `starlette`, `litestar` and `blacksheep`.
142
139
 
143
140
  ## Usage
144
141
 
@@ -1,7 +1,7 @@
1
1
  apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
2
2
  apitally/blacksheep.py,sha256=KvcPFeiwQgWZmRglbm8SLaN6_WRs5kZ3SymB1IuLR-A,9616
3
3
  apitally/common.py,sha256=azDxepViH0QW0MuufTHxeSQyLGzCkocAX_KPziWTx8A,1605
4
- apitally/django.py,sha256=MnyL6ntMYj1WndjLqpDZ-8BrbDWCeVV2dpZbe8Fnm-Y,19254
4
+ apitally/django.py,sha256=7eSh1tzuu0wRl9PxJgXMBSz9DfyohCDIqd4ecQTOJ1M,18499
5
5
  apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
6
6
  apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
7
7
  apitally/fastapi.py,sha256=IfKfgsmIY8_AtnuMTW2sW4qnkya61CAE2vBoIpcc9tk,169
@@ -15,12 +15,12 @@ apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbg
15
15
  apitally/client/client_threading.py,sha256=sxMRcxRgk1SxJjSq-qpIcDVmD3Q7Kv4CVT5zEUVt0KM,7257
16
16
  apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
17
17
  apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
18
- apitally/client/request_logging.py,sha256=SMvQd3WDolnb8u9rHVh2_OgXwFjL2jLZt-GpZNQ1XGk,14115
18
+ apitally/client/request_logging.py,sha256=yCF_CH8isp3gpQRVgakk6DNV60mtPZWG6z45pUNACOM,17644
19
19
  apitally/client/requests.py,sha256=SDptGOg9XvaEKFj2o3oxJz-JAuZzUrqpHnbOQixf99o,3794
20
20
  apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
21
21
  apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
22
22
  apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
23
- apitally-0.18.1.dist-info/METADATA,sha256=tlmfFn2vb-x63umo1V3lkZGHPWkB_A-Z6eGk9e5C2nI,9271
24
- apitally-0.18.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
- apitally-0.18.1.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
26
- apitally-0.18.1.dist-info/RECORD,,
23
+ apitally-0.19.0.dist-info/METADATA,sha256=9hbiC_z4DINCao5gg0F0Lqd9NVR8hZqNnnjpXkg9lnI,9316
24
+ apitally-0.19.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ apitally-0.19.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
26
+ apitally-0.19.0.dist-info/RECORD,,