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.
Files changed (38) hide show
  1. {reqly-0.5.0 → reqly-0.5.1}/PKG-INFO +1 -1
  2. {reqly-0.5.0 → reqly-0.5.1}/pyproject.toml +1 -1
  3. {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/fastapi.py +34 -15
  4. reqly-0.5.1/reqly/integrations/flask.py +80 -0
  5. {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/PKG-INFO +1 -1
  6. {reqly-0.5.0 → reqly-0.5.1}/tests/test_consumers_llm_wsgi.py +20 -0
  7. reqly-0.5.0/reqly/integrations/flask.py +0 -73
  8. {reqly-0.5.0 → reqly-0.5.1}/README.md +0 -0
  9. {reqly-0.5.0 → reqly-0.5.1}/reqly/__init__.py +0 -0
  10. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/__init__.py +0 -0
  11. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/buffer.py +0 -0
  12. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/capture.py +0 -0
  13. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/client.py +0 -0
  14. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/config.py +0 -0
  15. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/openapi_push.py +0 -0
  16. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/request_context.py +0 -0
  17. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/sampling.py +0 -0
  18. {reqly-0.5.0 → reqly-0.5.1}/reqly/core/shipper.py +0 -0
  19. {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/__init__.py +0 -0
  20. {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/django.py +0 -0
  21. {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/litestar.py +0 -0
  22. {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/starlette.py +0 -0
  23. {reqly-0.5.0 → reqly-0.5.1}/reqly/integrations/wsgi.py +0 -0
  24. {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/SOURCES.txt +0 -0
  25. {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/dependency_links.txt +0 -0
  26. {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/requires.txt +0 -0
  27. {reqly-0.5.0 → reqly-0.5.1}/reqly.egg-info/top_level.txt +0 -0
  28. {reqly-0.5.0 → reqly-0.5.1}/setup.cfg +0 -0
  29. {reqly-0.5.0 → reqly-0.5.1}/tests/test_buffer.py +0 -0
  30. {reqly-0.5.0 → reqly-0.5.1}/tests/test_capture.py +0 -0
  31. {reqly-0.5.0 → reqly-0.5.1}/tests/test_fastapi_integration.py +0 -0
  32. {reqly-0.5.0 → reqly-0.5.1}/tests/test_flask_integration.py +0 -0
  33. {reqly-0.5.0 → reqly-0.5.1}/tests/test_instrument.py +0 -0
  34. {reqly-0.5.0 → reqly-0.5.1}/tests/test_more_frameworks.py +0 -0
  35. {reqly-0.5.0 → reqly-0.5.1}/tests/test_openapi_push.py +0 -0
  36. {reqly-0.5.0 → reqly-0.5.1}/tests/test_sampling.py +0 -0
  37. {reqly-0.5.0 → reqly-0.5.1}/tests/test_shipper.py +0 -0
  38. {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.0
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.0"
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
- route_template = route_template_from_scope(scope, entry_root_path)
108
- if route_template is None and self._route_resolver is not None:
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
- normalized = normalize_route(scope.get("path", "/"), route_template)
111
- self._client.record_request(
112
- method=scope.get("method", "GET"),
113
- route=normalized,
114
- status_code=status_code,
115
- duration_ms=duration_ms,
116
- error=error or status_code >= 500,
117
- error_type=error_type,
118
- request_bytes=request_bytes,
119
- response_bytes=response_bytes,
120
- request_info=lambda: asgi_request_info(scope),
121
- llm=llm,
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.0
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