reqly 0.5.0__tar.gz → 0.5.1__tar.gz
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.
- {reqly-0.5.0 → reqly-0.5.1}/PKG-INFO +1 -1
- {reqly-0.5.0 → reqly-0.5.1}/pyproject.toml +1 -1
- {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/fastapi.py +34 -15
- reqly-0.5.1/reqly/integrations/flask.py +80 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/PKG-INFO +1 -1
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_consumers_llm_wsgi.py +20 -0
- reqly-0.5.0/reqly/integrations/flask.py +0 -73
- {reqly-0.5.0 → reqly-0.5.1}/README.md +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/__init__.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/__init__.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/buffer.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/capture.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/client.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/config.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/openapi_push.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/request_context.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/sampling.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/core/shipper.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/__init__.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/django.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/litestar.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/starlette.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/wsgi.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/SOURCES.txt +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/dependency_links.txt +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/requires.txt +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/top_level.txt +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/setup.cfg +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_buffer.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_capture.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_fastapi_integration.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_flask_integration.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_instrument.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_more_frameworks.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_openapi_push.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_sampling.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_shipper.py +0 -0
- {reqly-0.5.0 → reqly-0.5.1}/tests/test_v2_fields.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reqly
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Self-hosted API monitoring for FastAPI, Flask, Django, Starlette and Litestar in two lines: latency percentiles, error rates and release tracking per route, with deploy-aware alerts and weekly AI anomaly reports.
|
|
5
5
|
Author: Tanish Poddar
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reqly"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.1"
|
|
8
8
|
description = "Self-hosted API monitoring for FastAPI, Flask, Django, Starlette and Litestar in two lines: latency percentiles, error rates and release tracking per route, with deploy-aware alerts and weekly AI anomaly reports."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import time
|
|
4
5
|
from typing import Callable, Optional
|
|
5
6
|
|
|
@@ -7,6 +8,8 @@ from ..core.capture import normalize_route
|
|
|
7
8
|
from ..core.client import ReqlyClient
|
|
8
9
|
from ..core.request_context import RequestInfo, begin_request, end_request
|
|
9
10
|
|
|
11
|
+
logger = logging.getLogger("reqly")
|
|
12
|
+
|
|
10
13
|
RouteResolver = Callable[[dict], Optional[str]]
|
|
11
14
|
|
|
12
15
|
|
|
@@ -104,22 +107,38 @@ class ReqlyASGIMiddleware:
|
|
|
104
107
|
finally:
|
|
105
108
|
duration_ms = (time.perf_counter() - start) * 1000
|
|
106
109
|
llm = end_request(usage_token)
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
try:
|
|
111
|
+
self._record(scope, original_scope, entry_root_path, status_code, duration_ms,
|
|
112
|
+
error, error_type, request_bytes, response_bytes, llm)
|
|
113
|
+
except Exception:
|
|
114
|
+
# Fail-open: a broken route_resolver (user code with
|
|
115
|
+
# instrument_asgi) or anything else here must never reach
|
|
116
|
+
# the app -- the response has usually been sent already.
|
|
117
|
+
logger.warning("reqly: could not record ASGI request", exc_info=True)
|
|
118
|
+
|
|
119
|
+
def _record(self, scope, original_scope, entry_root_path, status_code, duration_ms,
|
|
120
|
+
error, error_type, request_bytes, response_bytes, llm) -> None:
|
|
121
|
+
route_template = route_template_from_scope(scope, entry_root_path)
|
|
122
|
+
if route_template is None and self._route_resolver is not None:
|
|
123
|
+
try:
|
|
109
124
|
route_template = self._route_resolver(original_scope)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
except Exception:
|
|
126
|
+
# Same as the WSGI wrapper: a failing resolver means "no
|
|
127
|
+
# route", not a lost event and not an error in the app.
|
|
128
|
+
logger.debug("reqly: route_resolver failed", exc_info=True)
|
|
129
|
+
normalized = normalize_route(scope.get("path", "/"), route_template)
|
|
130
|
+
self._client.record_request(
|
|
131
|
+
method=scope.get("method", "GET"),
|
|
132
|
+
route=normalized,
|
|
133
|
+
status_code=status_code,
|
|
134
|
+
duration_ms=duration_ms,
|
|
135
|
+
error=error or status_code >= 500,
|
|
136
|
+
error_type=error_type,
|
|
137
|
+
request_bytes=request_bytes,
|
|
138
|
+
response_bytes=response_bytes,
|
|
139
|
+
request_info=lambda: asgi_request_info(scope),
|
|
140
|
+
llm=llm,
|
|
141
|
+
)
|
|
123
142
|
|
|
124
143
|
|
|
125
144
|
def instrument_fastapi(app, client: ReqlyClient) -> None:
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from ..core.capture import normalize_route
|
|
7
|
+
from ..core.client import ReqlyClient
|
|
8
|
+
from ..core.request_context import RequestInfo, begin_request, end_request
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("reqly")
|
|
11
|
+
|
|
12
|
+
_START_TIME_ATTR = "_REQLY_start_time"
|
|
13
|
+
_USAGE_TOKEN_ATTR = "_REQLY_usage_token"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def instrument_flask(app, client: ReqlyClient) -> None:
|
|
17
|
+
"""Flask is WSGI, not ASGI -- uses request lifecycle signals rather than
|
|
18
|
+
wrapping ``app.wsgi_app`` directly. ``teardown_request`` matters
|
|
19
|
+
specifically because it fires even on unhandled exceptions, which is
|
|
20
|
+
exactly when error telemetry is most valuable.
|
|
21
|
+
"""
|
|
22
|
+
from flask import g, request
|
|
23
|
+
|
|
24
|
+
@app.before_request
|
|
25
|
+
def _REQLY_before_request():
|
|
26
|
+
setattr(g, _START_TIME_ATTR, time.perf_counter())
|
|
27
|
+
setattr(g, _USAGE_TOKEN_ATTR, begin_request())
|
|
28
|
+
|
|
29
|
+
@app.teardown_request
|
|
30
|
+
def _REQLY_teardown_request(exc):
|
|
31
|
+
start = getattr(g, _START_TIME_ATTR, None)
|
|
32
|
+
if start is None:
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
36
|
+
llm = end_request(getattr(g, _USAGE_TOKEN_ATTR, None))
|
|
37
|
+
|
|
38
|
+
route_template = None
|
|
39
|
+
if request.url_rule is not None:
|
|
40
|
+
route_template = request.url_rule.rule
|
|
41
|
+
normalized = normalize_route(request.path, route_template)
|
|
42
|
+
|
|
43
|
+
if exc is not None:
|
|
44
|
+
status_code = 500
|
|
45
|
+
error = True
|
|
46
|
+
error_type = type(exc).__name__
|
|
47
|
+
else:
|
|
48
|
+
response = getattr(g, "_REQLY_response_status", None)
|
|
49
|
+
status_code = response if response is not None else 200
|
|
50
|
+
error = status_code >= 500
|
|
51
|
+
error_type = None
|
|
52
|
+
|
|
53
|
+
client.record_request(
|
|
54
|
+
method=request.method,
|
|
55
|
+
route=normalized,
|
|
56
|
+
status_code=status_code,
|
|
57
|
+
duration_ms=duration_ms,
|
|
58
|
+
error=error,
|
|
59
|
+
error_type=error_type,
|
|
60
|
+
# Content-Length based: Flask can't count a streamed body without
|
|
61
|
+
# consuming it, so chunked requests/streamed responses report None.
|
|
62
|
+
request_bytes=request.content_length,
|
|
63
|
+
response_bytes=getattr(g, "_REQLY_response_bytes", None),
|
|
64
|
+
request_info=lambda: RequestInfo(
|
|
65
|
+
method=request.method,
|
|
66
|
+
path=request.path,
|
|
67
|
+
headers={k.lower(): v for k, v in request.headers.items()},
|
|
68
|
+
raw=request,
|
|
69
|
+
),
|
|
70
|
+
llm=llm,
|
|
71
|
+
)
|
|
72
|
+
except Exception:
|
|
73
|
+
# Fail-open: nothing here may turn into an error in the app.
|
|
74
|
+
logger.debug("reqly: could not record Flask request", exc_info=True)
|
|
75
|
+
|
|
76
|
+
@app.after_request
|
|
77
|
+
def _REQLY_after_request(response):
|
|
78
|
+
g._REQLY_response_status = response.status_code
|
|
79
|
+
g._REQLY_response_bytes = None if response.is_streamed else response.content_length
|
|
80
|
+
return response
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reqly
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Self-hosted API monitoring for FastAPI, Flask, Django, Starlette and Litestar in two lines: latency percentiles, error rates and release tracking per route, with deploy-aware alerts and weekly AI anomaly reports.
|
|
5
5
|
Author: Tanish Poddar
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -237,3 +237,23 @@ def test_instrument_asgi(fake_collector):
|
|
|
237
237
|
[event] = _events(app.client, batches)
|
|
238
238
|
assert (event["route"], event["status_code"], event["response_bytes"]) == ("/things", 201, 7)
|
|
239
239
|
app.client.shutdown()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_a_failing_route_resolver_never_reaches_the_app(fake_collector):
|
|
243
|
+
url, batches = fake_collector
|
|
244
|
+
|
|
245
|
+
async def raw_asgi(scope, receive, send):
|
|
246
|
+
await send({"type": "http.response.start", "status": 200, "headers": []})
|
|
247
|
+
await send({"type": "http.response.body", "body": b"ok"})
|
|
248
|
+
|
|
249
|
+
def broken(scope):
|
|
250
|
+
raise KeyError("route")
|
|
251
|
+
|
|
252
|
+
app = reqly.instrument_asgi(raw_asgi, service_name="a", collector_url=url, flush_interval_seconds=999,
|
|
253
|
+
route_resolver=broken)
|
|
254
|
+
from starlette.testclient import TestClient as ASGIClient
|
|
255
|
+
|
|
256
|
+
assert ASGIClient(app).get("/x").text == "ok"
|
|
257
|
+
[event] = _events(app.client, batches)
|
|
258
|
+
assert event["route"] == "__unmatched__" # recorded, without a route
|
|
259
|
+
app.client.shutdown()
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import time
|
|
4
|
-
|
|
5
|
-
from ..core.capture import normalize_route
|
|
6
|
-
from ..core.client import ReqlyClient
|
|
7
|
-
from ..core.request_context import RequestInfo, begin_request, end_request
|
|
8
|
-
|
|
9
|
-
_START_TIME_ATTR = "_REQLY_start_time"
|
|
10
|
-
_USAGE_TOKEN_ATTR = "_REQLY_usage_token"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def instrument_flask(app, client: ReqlyClient) -> None:
|
|
14
|
-
"""Flask is WSGI, not ASGI -- uses request lifecycle signals rather than
|
|
15
|
-
wrapping ``app.wsgi_app`` directly. ``teardown_request`` matters
|
|
16
|
-
specifically because it fires even on unhandled exceptions, which is
|
|
17
|
-
exactly when error telemetry is most valuable.
|
|
18
|
-
"""
|
|
19
|
-
from flask import g, request
|
|
20
|
-
|
|
21
|
-
@app.before_request
|
|
22
|
-
def _REQLY_before_request():
|
|
23
|
-
setattr(g, _START_TIME_ATTR, time.perf_counter())
|
|
24
|
-
setattr(g, _USAGE_TOKEN_ATTR, begin_request())
|
|
25
|
-
|
|
26
|
-
@app.teardown_request
|
|
27
|
-
def _REQLY_teardown_request(exc):
|
|
28
|
-
start = getattr(g, _START_TIME_ATTR, None)
|
|
29
|
-
if start is None:
|
|
30
|
-
return
|
|
31
|
-
duration_ms = (time.perf_counter() - start) * 1000
|
|
32
|
-
llm = end_request(getattr(g, _USAGE_TOKEN_ATTR, None))
|
|
33
|
-
|
|
34
|
-
route_template = None
|
|
35
|
-
if request.url_rule is not None:
|
|
36
|
-
route_template = request.url_rule.rule
|
|
37
|
-
normalized = normalize_route(request.path, route_template)
|
|
38
|
-
|
|
39
|
-
if exc is not None:
|
|
40
|
-
status_code = 500
|
|
41
|
-
error = True
|
|
42
|
-
error_type = type(exc).__name__
|
|
43
|
-
else:
|
|
44
|
-
response = getattr(g, "_REQLY_response_status", None)
|
|
45
|
-
status_code = response if response is not None else 200
|
|
46
|
-
error = status_code >= 500
|
|
47
|
-
error_type = None
|
|
48
|
-
|
|
49
|
-
client.record_request(
|
|
50
|
-
method=request.method,
|
|
51
|
-
route=normalized,
|
|
52
|
-
status_code=status_code,
|
|
53
|
-
duration_ms=duration_ms,
|
|
54
|
-
error=error,
|
|
55
|
-
error_type=error_type,
|
|
56
|
-
# Content-Length based: Flask can't count a streamed body without
|
|
57
|
-
# consuming it, so chunked requests/streamed responses report None.
|
|
58
|
-
request_bytes=request.content_length,
|
|
59
|
-
response_bytes=getattr(g, "_REQLY_response_bytes", None),
|
|
60
|
-
request_info=lambda: RequestInfo(
|
|
61
|
-
method=request.method,
|
|
62
|
-
path=request.path,
|
|
63
|
-
headers={k.lower(): v for k, v in request.headers.items()},
|
|
64
|
-
raw=request,
|
|
65
|
-
),
|
|
66
|
-
llm=llm,
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
@app.after_request
|
|
70
|
-
def _REQLY_after_request(response):
|
|
71
|
-
g._REQLY_response_status = response.status_code
|
|
72
|
-
g._REQLY_response_bytes = None if response.is_streamed else response.content_length
|
|
73
|
-
return response
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|