apitally 0.14.6__py3-none-any.whl → 0.15.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/client/request_logging.py +22 -6
- apitally/client/sentry.py +38 -0
- apitally/client/server_errors.py +37 -63
- apitally/common.py +15 -1
- apitally/django.py +11 -11
- apitally/flask.py +1 -0
- apitally/litestar.py +25 -26
- apitally/starlette.py +11 -13
- {apitally-0.14.6.dist-info → apitally-0.15.1.dist-info}/METADATA +17 -10
- {apitally-0.14.6.dist-info → apitally-0.15.1.dist-info}/RECORD +12 -11
- {apitally-0.14.6.dist-info → apitally-0.15.1.dist-info}/WHEEL +0 -0
- {apitally-0.14.6.dist-info → apitally-0.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -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[
|
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(
|
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"])
|
@@ -215,13 +225,19 @@ class RequestLogger:
|
|
215
225
|
request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
|
216
226
|
response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
|
217
227
|
|
218
|
-
item = {
|
228
|
+
item: Dict[str, Any] = {
|
219
229
|
"uuid": str(uuid4()),
|
220
230
|
"request": _skip_empty_values(request),
|
221
231
|
"response": _skip_empty_values(response),
|
222
232
|
}
|
223
|
-
|
224
|
-
|
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)
|
225
241
|
|
226
242
|
def write_to_file(self) -> None:
|
227
243
|
if not self.enabled or len(self.write_deque) == 0:
|
@@ -232,7 +248,7 @@ class RequestLogger:
|
|
232
248
|
while True:
|
233
249
|
try:
|
234
250
|
item = self.write_deque.popleft()
|
235
|
-
self.file.write_line(item)
|
251
|
+
self.file.write_line(self.serialize(item))
|
236
252
|
except IndexError:
|
237
253
|
break
|
238
254
|
|
@@ -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)
|
apitally/client/server_errors.py
CHANGED
@@ -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=
|
44
|
-
msg=
|
45
|
-
traceback=
|
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
|
-
|
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
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
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/common.py
CHANGED
@@ -1,6 +1,8 @@
|
|
1
|
+
import gzip
|
2
|
+
import json
|
1
3
|
import sys
|
2
4
|
from importlib.metadata import PackageNotFoundError, version
|
3
|
-
from typing import Dict, Optional, Union
|
5
|
+
from typing import Any, Dict, Optional, Union
|
4
6
|
|
5
7
|
|
6
8
|
def parse_int(x: Union[str, int, None]) -> Optional[int]:
|
@@ -12,6 +14,18 @@ def parse_int(x: Union[str, int, None]) -> Optional[int]:
|
|
12
14
|
return None
|
13
15
|
|
14
16
|
|
17
|
+
def try_json_loads(s: bytes, encoding: Optional[str] = None) -> Any:
|
18
|
+
if encoding is not None and encoding.lower() == "gzip":
|
19
|
+
try:
|
20
|
+
s = gzip.decompress(s)
|
21
|
+
except Exception:
|
22
|
+
pass
|
23
|
+
try:
|
24
|
+
return json.loads(s)
|
25
|
+
except Exception:
|
26
|
+
return None
|
27
|
+
|
28
|
+
|
15
29
|
def get_versions(*packages, app_version: Optional[str] = None) -> Dict[str, str]:
|
16
30
|
versions = _get_common_package_versions()
|
17
31
|
for package in packages:
|
apitally/django.py
CHANGED
@@ -22,7 +22,7 @@ from apitally.client.request_logging import (
|
|
22
22
|
RequestLogger,
|
23
23
|
RequestLoggingConfig,
|
24
24
|
)
|
25
|
-
from apitally.common import get_versions, parse_int
|
25
|
+
from apitally.common import get_versions, parse_int, try_json_loads
|
26
26
|
|
27
27
|
|
28
28
|
if TYPE_CHECKING:
|
@@ -173,16 +173,15 @@ class ApitallyMiddleware:
|
|
173
173
|
and content_type.startswith("application/json")
|
174
174
|
):
|
175
175
|
try:
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
)
|
176
|
+
body = try_json_loads(response.content, encoding=response.get("Content-Encoding"))
|
177
|
+
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
178
|
+
# Log Django Ninja / Pydantic validation errors
|
179
|
+
self.client.validation_error_counter.add_validation_errors(
|
180
|
+
consumer=consumer_identifier,
|
181
|
+
method=request.method,
|
182
|
+
path=path,
|
183
|
+
detail=body["detail"],
|
184
|
+
)
|
186
185
|
except Exception: # pragma: no cover
|
187
186
|
logger.exception("Failed to log validation errors")
|
188
187
|
|
@@ -216,6 +215,7 @@ class ApitallyMiddleware:
|
|
216
215
|
"size": response_size,
|
217
216
|
"body": response_body,
|
218
217
|
},
|
218
|
+
exception=getattr(request, "unhandled_exception", None),
|
219
219
|
)
|
220
220
|
else:
|
221
221
|
response = self.get_response(request)
|
apitally/flask.py
CHANGED
apitally/litestar.py
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
import contextlib
|
2
1
|
import json
|
3
2
|
import time
|
4
3
|
from typing import Callable, Dict, List, Optional, Union
|
@@ -22,7 +21,7 @@ from apitally.client.request_logging import (
|
|
22
21
|
RequestLogger,
|
23
22
|
RequestLoggingConfig,
|
24
23
|
)
|
25
|
-
from apitally.common import get_versions, parse_int
|
24
|
+
from apitally.common import get_versions, parse_int, try_json_loads
|
26
25
|
|
27
26
|
|
28
27
|
__all__ = ["ApitallyPlugin", "ApitallyConsumer", "RequestLoggingConfig"]
|
@@ -199,30 +198,29 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
199
198
|
)
|
200
199
|
|
201
200
|
if response_status == 400 and response_body and len(response_body) < 4096:
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
)
|
201
|
+
body = try_json_loads(response_body, encoding=response_headers.get("Content-Encoding"))
|
202
|
+
if (
|
203
|
+
isinstance(body, dict)
|
204
|
+
and "detail" in body
|
205
|
+
and isinstance(body["detail"], str)
|
206
|
+
and "validation" in body["detail"].lower()
|
207
|
+
and "extra" in body
|
208
|
+
and isinstance(body["extra"], list)
|
209
|
+
):
|
210
|
+
self.client.validation_error_counter.add_validation_errors(
|
211
|
+
consumer=consumer_identifier,
|
212
|
+
method=request.method,
|
213
|
+
path=path,
|
214
|
+
detail=[
|
215
|
+
{
|
216
|
+
"loc": [error.get("source", "body")] + error["key"].split("."),
|
217
|
+
"msg": error["message"],
|
218
|
+
"type": "",
|
219
|
+
}
|
220
|
+
for error in body["extra"]
|
221
|
+
if "key" in error and "message" in error
|
222
|
+
],
|
223
|
+
)
|
226
224
|
|
227
225
|
if response_status == 500 and "exception" in request.state:
|
228
226
|
self.client.server_error_counter.add_server_error(
|
@@ -251,6 +249,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
251
249
|
"size": response_size,
|
252
250
|
"body": response_body,
|
253
251
|
},
|
252
|
+
exception=request.state["exception"] if "exception" in request.state else None,
|
254
253
|
)
|
255
254
|
|
256
255
|
def get_path(self, request: Request) -> Optional[str]:
|
apitally/starlette.py
CHANGED
@@ -1,8 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import asyncio
|
4
|
-
import contextlib
|
5
|
-
import json
|
6
4
|
import time
|
7
5
|
from typing import Any, Callable, Dict, List, Optional, Union
|
8
6
|
from warnings import warn
|
@@ -23,7 +21,7 @@ from apitally.client.request_logging import (
|
|
23
21
|
RequestLogger,
|
24
22
|
RequestLoggingConfig,
|
25
23
|
)
|
26
|
-
from apitally.common import get_versions, parse_int
|
24
|
+
from apitally.common import get_versions, parse_int, try_json_loads
|
27
25
|
|
28
26
|
|
29
27
|
__all__ = ["ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
|
@@ -191,16 +189,15 @@ class ApitallyMiddleware:
|
|
191
189
|
response_size=response_size,
|
192
190
|
)
|
193
191
|
if response_status == 422 and response_body and response_headers.get("Content-Type") == "application/json":
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
)
|
192
|
+
body = try_json_loads(response_body, encoding=response_headers.get("Content-Encoding"))
|
193
|
+
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
194
|
+
# Log FastAPI / Pydantic validation errors
|
195
|
+
self.client.validation_error_counter.add_validation_errors(
|
196
|
+
consumer=consumer_identifier,
|
197
|
+
method=request.method,
|
198
|
+
path=path,
|
199
|
+
detail=body["detail"],
|
200
|
+
)
|
204
201
|
if response_status == 500 and exception is not None:
|
205
202
|
self.client.server_error_counter.add_server_error(
|
206
203
|
consumer=consumer_identifier,
|
@@ -228,6 +225,7 @@ class ApitallyMiddleware:
|
|
228
225
|
"size": response_size,
|
229
226
|
"body": response_body,
|
230
227
|
},
|
228
|
+
exception=exception,
|
231
229
|
)
|
232
230
|
|
233
231
|
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.
|
3
|
+
Version: 0.15.1
|
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
|
@@ -80,14 +80,13 @@ Description-Content-Type: text/markdown
|
|
80
80
|
|
81
81
|
---
|
82
82
|
|
83
|
-
# Apitally
|
83
|
+
# Apitally SDK for Python
|
84
84
|
|
85
85
|
[](https://github.com/apitally/apitally-py/actions)
|
86
86
|
[](https://codecov.io/gh/apitally/apitally-py)
|
87
87
|
[](https://pypi.org/project/apitally/)
|
88
88
|
|
89
|
-
This
|
90
|
-
frameworks:
|
89
|
+
This SDK for Apitally currently supports the following Python web frameworks:
|
91
90
|
|
92
91
|
- [FastAPI](https://docs.apitally.io/frameworks/fastapi)
|
93
92
|
- [Django REST Framework](https://docs.apitally.io/frameworks/django-rest-framework)
|
@@ -103,19 +102,27 @@ the 📚 [documentation](https://docs.apitally.io).
|
|
103
102
|
|
104
103
|
### API analytics
|
105
104
|
|
106
|
-
Track traffic, error and performance metrics for your API, each endpoint and
|
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.
|
107
108
|
|
108
109
|
### Error tracking
|
109
110
|
|
110
|
-
Understand which validation rules in your endpoints cause client errors. Capture
|
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.
|
111
114
|
|
112
115
|
### Request logging
|
113
116
|
|
114
|
-
Drill down from insights to individual requests or use powerful filtering to
|
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.
|
115
120
|
|
116
121
|
### API monitoring & alerting
|
117
122
|
|
118
|
-
Get notified immediately if something isn't right using custom alerts, synthetic
|
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.
|
119
126
|
|
120
127
|
## Install
|
121
128
|
|
@@ -191,8 +198,8 @@ app.wsgi_app = ApitallyMiddleware(
|
|
191
198
|
|
192
199
|
### Starlette
|
193
200
|
|
194
|
-
This is an example of how to add the Apitally middleware to a Starlette
|
195
|
-
For further instructions, see our
|
201
|
+
This is an example of how to add the Apitally middleware to a Starlette
|
202
|
+
application. For further instructions, see our
|
196
203
|
[setup guide for Starlette](https://docs.apitally.io/frameworks/starlette).
|
197
204
|
|
198
205
|
```python
|
@@ -1,24 +1,25 @@
|
|
1
1
|
apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
|
2
|
-
apitally/common.py,sha256=
|
3
|
-
apitally/django.py,sha256=
|
2
|
+
apitally/common.py,sha256=FMDBPlYHCqomgAq-Z8JiyTSMAoqJRycPsJzsxncQqQA,1598
|
3
|
+
apitally/django.py,sha256=zwe8svC8rfo7TyHfOlkYTeXptxPFoRjvt0bbYvgtJKM,16892
|
4
4
|
apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
5
5
|
apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
|
6
6
|
apitally/fastapi.py,sha256=IfKfgsmIY8_AtnuMTW2sW4qnkya61CAE2vBoIpcc9tk,169
|
7
|
-
apitally/flask.py,sha256=
|
8
|
-
apitally/litestar.py,sha256=
|
7
|
+
apitally/flask.py,sha256=p_u33_FQq2i5AebWB8wYxXX0CPhcX8OJHGWj5dR4sPY,9622
|
8
|
+
apitally/litestar.py,sha256=mHoMqBO_gyoopeHljY8e8GTcV29UDf3uhQMxY3GeNpA,13451
|
9
9
|
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
|
-
apitally/starlette.py,sha256=
|
10
|
+
apitally/starlette.py,sha256=iEcN--2eeUW9d78H42WolWEkss2idvXLjK2OQmvULdM,13218
|
11
11
|
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
12
|
apitally/client/client_asyncio.py,sha256=9mdi9Hmb6-xn7dNdwP84e4PNAHGg2bYdMEgIfPUAtcQ,7003
|
13
13
|
apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbgw,3799
|
14
14
|
apitally/client/client_threading.py,sha256=7JPu2Uulev7X2RiSLx4HJYfvAP6Z5zB_yuSevMfQC7I,7389
|
15
15
|
apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
|
16
16
|
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
17
|
-
apitally/client/request_logging.py,sha256=
|
17
|
+
apitally/client/request_logging.py,sha256=OL1jlpHXYpZw2VKRekgNSwZQ0qZJeiiJxKeTYrmP22g,13913
|
18
18
|
apitally/client/requests.py,sha256=RdJyvIqQGVHvS-wjpAPUwcO7byOJ6jO8dYqNTU2Furg,3685
|
19
|
-
apitally/client/
|
19
|
+
apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
|
20
|
+
apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
|
20
21
|
apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
|
21
|
-
apitally-0.
|
22
|
-
apitally-0.
|
23
|
-
apitally-0.
|
24
|
-
apitally-0.
|
22
|
+
apitally-0.15.1.dist-info/METADATA,sha256=Z8Es_x6H-rxC5KB26S0i1BFGVBlISkQfNiYPglyS09E,8643
|
23
|
+
apitally-0.15.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
24
|
+
apitally-0.15.1.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
25
|
+
apitally-0.15.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|