reqly 0.3.0__tar.gz → 0.5.0__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.3.0 → reqly-0.5.0}/PKG-INFO +42 -3
- {reqly-0.3.0 → reqly-0.5.0}/README.md +41 -2
- {reqly-0.3.0 → reqly-0.5.0}/pyproject.toml +1 -1
- {reqly-0.3.0 → reqly-0.5.0}/reqly/__init__.py +101 -2
- {reqly-0.3.0 → reqly-0.5.0}/reqly/core/capture.py +11 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/core/client.py +40 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/core/config.py +24 -0
- reqly-0.5.0/reqly/core/openapi_push.py +58 -0
- reqly-0.5.0/reqly/core/request_context.py +167 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/django.py +22 -6
- {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/fastapi.py +13 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/flask.py +11 -0
- reqly-0.5.0/reqly/integrations/wsgi.py +157 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/PKG-INFO +42 -3
- {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/SOURCES.txt +5 -0
- reqly-0.5.0/tests/test_consumers_llm_wsgi.py +239 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_more_frameworks.py +20 -0
- reqly-0.5.0/tests/test_openapi_push.py +170 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/core/__init__.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/core/buffer.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/core/sampling.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/core/shipper.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/__init__.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/litestar.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/starlette.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/dependency_links.txt +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/requires.txt +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/top_level.txt +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/setup.cfg +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_buffer.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_capture.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_fastapi_integration.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_flask_integration.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_instrument.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_sampling.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_shipper.py +0 -0
- {reqly-0.3.0 → reqly-0.5.0}/tests/test_v2_fields.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reqly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
@@ -110,6 +110,34 @@ MIDDLEWARE = [
|
|
|
110
110
|
REQLY = {"service_name": "checkout-api", "api_key": "your-ingest-key"} # optional
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
**Any other WSGI or ASGI app** (Bottle, Pyramid, Falcon, CherryPy, a bare ASGI app) — wrap it
|
|
114
|
+
and serve the result. A `route_resolver` returns the route template, because only the framework
|
|
115
|
+
knows it; without one every request is recorded as `__unmatched__`, never as a raw path:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
app = reqly.instrument_wsgi(
|
|
119
|
+
app, service_name="checkout-api",
|
|
120
|
+
route_resolver=lambda environ: environ["bottle.route"].rule, # Bottle
|
|
121
|
+
)
|
|
122
|
+
# Pyramid: environ["bfg.routes.route"].pattern; ASGI: reqly.instrument_asgi(app, route_resolver=...)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Who is calling** — tag each request with its API consumer. The id is hashed (HMAC-SHA256
|
|
126
|
+
with your secret salt) before it leaves the app:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
reqly.instrument(app, consumer_header="X-API-Key", consumer_salt=os.environ["REQLY_CONSUMER_SALT"])
|
|
130
|
+
# or any logic: consumer=lambda info: info.headers.get("x-tenant-id")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**LLM cost per route** — record token usage where you call a model; the collector prices it:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
completion = client.chat.completions.create(model="gpt-4o-mini", messages=messages)
|
|
137
|
+
reqly.record_llm_response(completion) # OpenAI / Anthropic responses, or:
|
|
138
|
+
reqly.record_llm_usage("gpt-4o-mini", input_tokens=1200, output_tokens=240)
|
|
139
|
+
```
|
|
140
|
+
|
|
113
141
|
`instrument()` detects the framework by itself — no decorators, no middleware to wire up.
|
|
114
142
|
Routes are recorded as templates in one style across frameworks: Django's
|
|
115
143
|
`users/<int:pk>/` and DRF's `^users/(?P<pk>[^/.]+)/$` both become `/users/{pk}/`.
|
|
@@ -131,7 +159,12 @@ In the Reqly dashboard and collector:
|
|
|
131
159
|
- **Deploy markers and per-release health** — each release's error rate and p95
|
|
132
160
|
- **Hourly alerts** to Slack, Discord or a webhook when a route breaks from its usual
|
|
133
161
|
weekday-hour pattern, with **root-cause hints**
|
|
134
|
-
- **
|
|
162
|
+
- **Top consumers** — requests and error rate per API client, and which clients an incident
|
|
163
|
+
hit (in the alert itself)
|
|
164
|
+
- **LLM cost per route** — tokens and estimated spend per route and model
|
|
165
|
+
- **API surface vs your OpenAPI spec** — undocumented endpoints that get traffic, documented
|
|
166
|
+
ones nobody calls, and deprecated ones still in use (`push_openapi=True`)
|
|
167
|
+
- **Weekly AI report** — statistics find the anomalies, Groq (gpt-oss-120b) writes the
|
|
135
168
|
summary; plain-text fallback without an API key
|
|
136
169
|
|
|
137
170
|
An alert from the demo data looks like this:
|
|
@@ -164,6 +197,11 @@ Resolution order: **argument → environment variable → default**.
|
|
|
164
197
|
| `max_batch_size` | `REQLY_MAX_BATCH_SIZE` | `200` |
|
|
165
198
|
| `max_queue_size` | `REQLY_MAX_QUEUE_SIZE` | `2000` |
|
|
166
199
|
| `ignore_routes` | `REQLY_IGNORE_ROUTES` (comma-separated) | `/health,/metrics` |
|
|
200
|
+
| `consumer_header` | `REQLY_CONSUMER_HEADER` | `None` — header that identifies the caller, e.g. `X-API-Key` |
|
|
201
|
+
| `consumer` | — | `None` — `callable(RequestInfo) -> str \| None`, instead of a header |
|
|
202
|
+
| `consumer_salt` | `REQLY_CONSUMER_SALT` | `None` — secret for hashing consumer ids (set it) |
|
|
203
|
+
| `hash_consumer` | `REQLY_HASH_CONSUMER` | `True` — `False` sends ids unhashed (only for non-secret ids) |
|
|
204
|
+
| `push_openapi` | `REQLY_PUSH_OPENAPI` | `False` — upload the app's OpenAPI spec (FastAPI, Litestar) on the first request |
|
|
167
205
|
| `capture_request_body` | `REQLY_CAPTURE_REQUEST_BODY` | `False` (not implemented yet) |
|
|
168
206
|
|
|
169
207
|
With `sample_rate` below 1.0, request counts in the dashboard are the sampled volume;
|
|
@@ -201,7 +239,8 @@ are shipped instead of silently queuing forever.
|
|
|
201
239
|
| Litestar | 2.0+ |
|
|
202
240
|
| Flask | 2.3+ |
|
|
203
241
|
| Django | 4.2+, sync and async views; DRF and Django Ninja |
|
|
204
|
-
|
|
|
242
|
+
| Other WSGI / ASGI | any, with `instrument_wsgi()` / `instrument_asgi()` and a `route_resolver` |
|
|
243
|
+
| Collector | any version; `release`, `environment` and body sizes are stored by collector 0.3.0+ and ignored by older ones; `push_openapi` needs 0.7.0+; consumer and LLM views need 0.8.0+ |
|
|
205
244
|
|
|
206
245
|
## Self-hosting the collector
|
|
207
246
|
|
|
@@ -63,6 +63,34 @@ MIDDLEWARE = [
|
|
|
63
63
|
REQLY = {"service_name": "checkout-api", "api_key": "your-ingest-key"} # optional
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
**Any other WSGI or ASGI app** (Bottle, Pyramid, Falcon, CherryPy, a bare ASGI app) — wrap it
|
|
67
|
+
and serve the result. A `route_resolver` returns the route template, because only the framework
|
|
68
|
+
knows it; without one every request is recorded as `__unmatched__`, never as a raw path:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
app = reqly.instrument_wsgi(
|
|
72
|
+
app, service_name="checkout-api",
|
|
73
|
+
route_resolver=lambda environ: environ["bottle.route"].rule, # Bottle
|
|
74
|
+
)
|
|
75
|
+
# Pyramid: environ["bfg.routes.route"].pattern; ASGI: reqly.instrument_asgi(app, route_resolver=...)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Who is calling** — tag each request with its API consumer. The id is hashed (HMAC-SHA256
|
|
79
|
+
with your secret salt) before it leaves the app:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
reqly.instrument(app, consumer_header="X-API-Key", consumer_salt=os.environ["REQLY_CONSUMER_SALT"])
|
|
83
|
+
# or any logic: consumer=lambda info: info.headers.get("x-tenant-id")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**LLM cost per route** — record token usage where you call a model; the collector prices it:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
completion = client.chat.completions.create(model="gpt-4o-mini", messages=messages)
|
|
90
|
+
reqly.record_llm_response(completion) # OpenAI / Anthropic responses, or:
|
|
91
|
+
reqly.record_llm_usage("gpt-4o-mini", input_tokens=1200, output_tokens=240)
|
|
92
|
+
```
|
|
93
|
+
|
|
66
94
|
`instrument()` detects the framework by itself — no decorators, no middleware to wire up.
|
|
67
95
|
Routes are recorded as templates in one style across frameworks: Django's
|
|
68
96
|
`users/<int:pk>/` and DRF's `^users/(?P<pk>[^/.]+)/$` both become `/users/{pk}/`.
|
|
@@ -84,7 +112,12 @@ In the Reqly dashboard and collector:
|
|
|
84
112
|
- **Deploy markers and per-release health** — each release's error rate and p95
|
|
85
113
|
- **Hourly alerts** to Slack, Discord or a webhook when a route breaks from its usual
|
|
86
114
|
weekday-hour pattern, with **root-cause hints**
|
|
87
|
-
- **
|
|
115
|
+
- **Top consumers** — requests and error rate per API client, and which clients an incident
|
|
116
|
+
hit (in the alert itself)
|
|
117
|
+
- **LLM cost per route** — tokens and estimated spend per route and model
|
|
118
|
+
- **API surface vs your OpenAPI spec** — undocumented endpoints that get traffic, documented
|
|
119
|
+
ones nobody calls, and deprecated ones still in use (`push_openapi=True`)
|
|
120
|
+
- **Weekly AI report** — statistics find the anomalies, Groq (gpt-oss-120b) writes the
|
|
88
121
|
summary; plain-text fallback without an API key
|
|
89
122
|
|
|
90
123
|
An alert from the demo data looks like this:
|
|
@@ -117,6 +150,11 @@ Resolution order: **argument → environment variable → default**.
|
|
|
117
150
|
| `max_batch_size` | `REQLY_MAX_BATCH_SIZE` | `200` |
|
|
118
151
|
| `max_queue_size` | `REQLY_MAX_QUEUE_SIZE` | `2000` |
|
|
119
152
|
| `ignore_routes` | `REQLY_IGNORE_ROUTES` (comma-separated) | `/health,/metrics` |
|
|
153
|
+
| `consumer_header` | `REQLY_CONSUMER_HEADER` | `None` — header that identifies the caller, e.g. `X-API-Key` |
|
|
154
|
+
| `consumer` | — | `None` — `callable(RequestInfo) -> str \| None`, instead of a header |
|
|
155
|
+
| `consumer_salt` | `REQLY_CONSUMER_SALT` | `None` — secret for hashing consumer ids (set it) |
|
|
156
|
+
| `hash_consumer` | `REQLY_HASH_CONSUMER` | `True` — `False` sends ids unhashed (only for non-secret ids) |
|
|
157
|
+
| `push_openapi` | `REQLY_PUSH_OPENAPI` | `False` — upload the app's OpenAPI spec (FastAPI, Litestar) on the first request |
|
|
120
158
|
| `capture_request_body` | `REQLY_CAPTURE_REQUEST_BODY` | `False` (not implemented yet) |
|
|
121
159
|
|
|
122
160
|
With `sample_rate` below 1.0, request counts in the dashboard are the sampled volume;
|
|
@@ -154,7 +192,8 @@ are shipped instead of silently queuing forever.
|
|
|
154
192
|
| Litestar | 2.0+ |
|
|
155
193
|
| Flask | 2.3+ |
|
|
156
194
|
| Django | 4.2+, sync and async views; DRF and Django Ninja |
|
|
157
|
-
|
|
|
195
|
+
| Other WSGI / ASGI | any, with `instrument_wsgi()` / `instrument_asgi()` and a `route_resolver` |
|
|
196
|
+
| Collector | any version; `release`, `environment` and body sizes are stored by collector 0.3.0+ and ignored by older ones; `push_openapi` needs 0.7.0+; consumer and LLM views need 0.8.0+ |
|
|
158
197
|
|
|
159
198
|
## Self-hosting the collector
|
|
160
199
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reqly"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
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"
|
|
@@ -4,10 +4,18 @@ import logging
|
|
|
4
4
|
|
|
5
5
|
from .core.client import ReqlyClient
|
|
6
6
|
from .core.config import Config, _get_sdk_version
|
|
7
|
+
from .core.request_context import RequestInfo, record_llm_response, record_llm_usage
|
|
7
8
|
|
|
8
9
|
__version__ = _get_sdk_version()
|
|
9
10
|
|
|
10
|
-
__all__ = [
|
|
11
|
+
__all__ = [
|
|
12
|
+
"instrument",
|
|
13
|
+
"instrument_asgi",
|
|
14
|
+
"instrument_wsgi",
|
|
15
|
+
"record_llm_usage",
|
|
16
|
+
"record_llm_response",
|
|
17
|
+
"RequestInfo",
|
|
18
|
+
]
|
|
11
19
|
|
|
12
20
|
logger = logging.getLogger("reqly")
|
|
13
21
|
|
|
@@ -34,10 +42,24 @@ def _detect_framework(app) -> str:
|
|
|
34
42
|
raise TypeError(
|
|
35
43
|
"reqly.instrument(): could not detect framework for app of type "
|
|
36
44
|
f"{type(app)!r}. Supported: FastAPI, Starlette, Litestar, Flask "
|
|
37
|
-
"(Django: add reqly.integrations.django.ReqlyMiddleware to MIDDLEWARE
|
|
45
|
+
"(Django: add reqly.integrations.django.ReqlyMiddleware to MIDDLEWARE; any other "
|
|
46
|
+
"WSGI/ASGI app: reqly.instrument_wsgi() / reqly.instrument_asgi())."
|
|
38
47
|
)
|
|
39
48
|
|
|
40
49
|
|
|
50
|
+
def _enable_openapi_push(app, framework: str, client: ReqlyClient) -> None:
|
|
51
|
+
if framework == "fastapi":
|
|
52
|
+
client.enable_openapi_push(app.openapi)
|
|
53
|
+
elif framework == "litestar":
|
|
54
|
+
client.enable_openapi_push(lambda: app.openapi_schema.to_schema())
|
|
55
|
+
else:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"reqly: push_openapi needs an app that generates its own spec (FastAPI, Litestar); "
|
|
58
|
+
"for %s, upload the spec with PUT /v1/services/<service>/openapi instead",
|
|
59
|
+
framework,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
41
63
|
def instrument(
|
|
42
64
|
app,
|
|
43
65
|
*,
|
|
@@ -52,6 +74,11 @@ def instrument(
|
|
|
52
74
|
capture_request_body: bool | None = None,
|
|
53
75
|
release: str | None = None,
|
|
54
76
|
environment: str | None = None,
|
|
77
|
+
push_openapi: bool | None = None,
|
|
78
|
+
consumer_header: str | None = None,
|
|
79
|
+
consumer=None,
|
|
80
|
+
consumer_salt: str | None = None,
|
|
81
|
+
hash_consumer: bool | None = None,
|
|
55
82
|
) -> ReqlyClient | None:
|
|
56
83
|
"""Instrument a FastAPI, Starlette, Litestar or Flask app with one line.
|
|
57
84
|
(Django: add ``reqly.integrations.django.ReqlyMiddleware`` to MIDDLEWARE.)
|
|
@@ -60,6 +87,16 @@ def instrument(
|
|
|
60
87
|
environment variable (REQLY_*) > default. See core.config.Config
|
|
61
88
|
for the full list of environment variables.
|
|
62
89
|
|
|
90
|
+
Consumers (who is calling): ``consumer_header="X-API-Key"`` or
|
|
91
|
+
``consumer=lambda info: ...`` (gets a RequestInfo, returns an id or
|
|
92
|
+
None). Ids are HMAC-SHA256-hashed with ``consumer_salt``
|
|
93
|
+
(REQLY_CONSUMER_SALT) before leaving the app unless ``hash_consumer=False``.
|
|
94
|
+
|
|
95
|
+
``push_openapi=True`` (or REQLY_PUSH_OPENAPI=true) uploads the app's
|
|
96
|
+
OpenAPI spec (FastAPI, Litestar) to the collector on the first request,
|
|
97
|
+
so the dashboard can show undocumented, unused and deprecated-but-used
|
|
98
|
+
endpoints.
|
|
99
|
+
|
|
63
100
|
This function itself is guarded: a failure to detect the framework or
|
|
64
101
|
initialize the client is logged and the app is returned uninstrumented
|
|
65
102
|
rather than raising, so adding Reqly can never be the reason an
|
|
@@ -87,8 +124,15 @@ def instrument(
|
|
|
87
124
|
capture_request_body=capture_request_body,
|
|
88
125
|
release=release,
|
|
89
126
|
environment=environment,
|
|
127
|
+
push_openapi=push_openapi,
|
|
128
|
+
consumer_header=consumer_header,
|
|
129
|
+
consumer=consumer,
|
|
130
|
+
consumer_salt=consumer_salt,
|
|
131
|
+
hash_consumer=hash_consumer,
|
|
90
132
|
)
|
|
91
133
|
client = ReqlyClient(config)
|
|
134
|
+
if config.push_openapi:
|
|
135
|
+
_enable_openapi_push(app, framework, client)
|
|
92
136
|
|
|
93
137
|
if config.capture_request_body:
|
|
94
138
|
logger.warning(
|
|
@@ -121,3 +165,58 @@ def instrument(
|
|
|
121
165
|
exc_info=True,
|
|
122
166
|
)
|
|
123
167
|
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_GENERIC_OPTIONS = (
|
|
171
|
+
"service_name", "collector_url", "api_key", "sample_rate", "flush_interval_seconds",
|
|
172
|
+
"max_batch_size", "max_queue_size", "ignore_routes", "capture_request_body",
|
|
173
|
+
"release", "environment", "consumer_header", "consumer", "consumer_salt", "hash_consumer",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _generic_client(kind: str, route_resolver, options: dict) -> ReqlyClient:
|
|
178
|
+
if route_resolver is None:
|
|
179
|
+
logger.warning(
|
|
180
|
+
"reqly: instrument_%s() without route_resolver records every request as "
|
|
181
|
+
"__unmatched__; pass a function that returns the route template", kind,
|
|
182
|
+
)
|
|
183
|
+
if options.pop("push_openapi", None):
|
|
184
|
+
logger.warning("reqly: push_openapi is not available for generic %s apps", kind.upper())
|
|
185
|
+
unknown = set(options) - set(_GENERIC_OPTIONS)
|
|
186
|
+
if unknown:
|
|
187
|
+
logger.warning("reqly: instrument_%s() ignoring unknown options %s", kind, sorted(unknown))
|
|
188
|
+
return ReqlyClient(Config.resolve(**{key: options.get(key) for key in _GENERIC_OPTIONS}))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def instrument_wsgi(app, *, route_resolver=None, **options):
|
|
192
|
+
"""Wrap any WSGI app (Bottle, Pyramid, Falcon, CherryPy...) and return
|
|
193
|
+
the wrapped app -- serve that one::
|
|
194
|
+
|
|
195
|
+
app = reqly.instrument_wsgi(app, service_name="api",
|
|
196
|
+
route_resolver=lambda environ: environ["bottle.route"].rule)
|
|
197
|
+
|
|
198
|
+
``route_resolver(environ)`` runs after the app handled the request and
|
|
199
|
+
returns the route template or None. ``options`` are those of
|
|
200
|
+
``instrument()``. Like ``instrument()``, a failure leaves the app
|
|
201
|
+
unwrapped instead of raising."""
|
|
202
|
+
try:
|
|
203
|
+
from .integrations.wsgi import ReqlyWSGIMiddleware
|
|
204
|
+
|
|
205
|
+
return ReqlyWSGIMiddleware(app, _generic_client("wsgi", route_resolver, options), route_resolver)
|
|
206
|
+
except Exception:
|
|
207
|
+
logger.warning("reqly: instrument_wsgi() failed, app will run uninstrumented", exc_info=True)
|
|
208
|
+
return app
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def instrument_asgi(app, *, route_resolver=None, **options):
|
|
212
|
+
"""Wrap any ASGI app and return the wrapped app. ``route_resolver(scope)``
|
|
213
|
+
gets a copy of the scope as it arrived; a template the framework puts in
|
|
214
|
+
``scope["route"]`` or ``scope["path_template"]`` is used first."""
|
|
215
|
+
try:
|
|
216
|
+
from .integrations.fastapi import ReqlyASGIMiddleware
|
|
217
|
+
|
|
218
|
+
client = _generic_client("asgi", route_resolver, options)
|
|
219
|
+
return ReqlyASGIMiddleware(app, client, route_resolver)
|
|
220
|
+
except Exception:
|
|
221
|
+
logger.warning("reqly: instrument_asgi() failed, app will run uninstrumented", exc_info=True)
|
|
222
|
+
return app
|
|
@@ -31,6 +31,10 @@ class RequestEvent:
|
|
|
31
31
|
host: str = _HOSTNAME
|
|
32
32
|
request_bytes: int | None = None
|
|
33
33
|
response_bytes: int | None = None
|
|
34
|
+
consumer_id: str | None = None
|
|
35
|
+
llm_model: str | None = None
|
|
36
|
+
llm_input_tokens: int | None = None
|
|
37
|
+
llm_output_tokens: int | None = None
|
|
34
38
|
sdk_version: str = field(default_factory=_get_sdk_version)
|
|
35
39
|
|
|
36
40
|
def to_dict(self) -> dict:
|
|
@@ -59,7 +63,10 @@ def build_event(
|
|
|
59
63
|
sdk_version: str,
|
|
60
64
|
request_bytes: int | None = None,
|
|
61
65
|
response_bytes: int | None = None,
|
|
66
|
+
consumer_id: str | None = None,
|
|
67
|
+
llm: tuple[str, int, int] | None = None,
|
|
62
68
|
) -> RequestEvent:
|
|
69
|
+
llm_model, llm_input_tokens, llm_output_tokens = llm if llm else (None, None, None)
|
|
63
70
|
return RequestEvent(
|
|
64
71
|
service_name=service_name,
|
|
65
72
|
method=method,
|
|
@@ -71,4 +78,8 @@ def build_event(
|
|
|
71
78
|
sdk_version=sdk_version,
|
|
72
79
|
request_bytes=request_bytes,
|
|
73
80
|
response_bytes=response_bytes,
|
|
81
|
+
consumer_id=consumer_id,
|
|
82
|
+
llm_model=llm_model,
|
|
83
|
+
llm_input_tokens=llm_input_tokens,
|
|
84
|
+
llm_output_tokens=llm_output_tokens,
|
|
74
85
|
)
|
|
@@ -5,6 +5,8 @@ import logging
|
|
|
5
5
|
from .buffer import EventBuffer
|
|
6
6
|
from .capture import build_event
|
|
7
7
|
from .config import Config
|
|
8
|
+
from .openapi_push import OpenAPIPusher
|
|
9
|
+
from .request_context import ConsumerResolver
|
|
8
10
|
from .sampling import Sampler
|
|
9
11
|
from .shipper import Shipper
|
|
10
12
|
|
|
@@ -26,8 +28,17 @@ class ReqlyClient:
|
|
|
26
28
|
self.config = config
|
|
27
29
|
self._disabled = False
|
|
28
30
|
self._ignore_routes = set(config.ignore_routes)
|
|
31
|
+
self._openapi_pusher: OpenAPIPusher | None = None
|
|
32
|
+
self._consumers: ConsumerResolver | None = None
|
|
29
33
|
|
|
30
34
|
try:
|
|
35
|
+
if config.consumer_header or config.consumer is not None:
|
|
36
|
+
self._consumers = ConsumerResolver(
|
|
37
|
+
header=config.consumer_header,
|
|
38
|
+
func=config.consumer,
|
|
39
|
+
salt=config.consumer_salt,
|
|
40
|
+
hash_ids=config.hash_consumer,
|
|
41
|
+
)
|
|
31
42
|
self._sampler = Sampler(config.sample_rate)
|
|
32
43
|
shipper = Shipper(
|
|
33
44
|
collector_url=config.collector_url,
|
|
@@ -61,15 +72,24 @@ class ReqlyClient:
|
|
|
61
72
|
error_type: str | None,
|
|
62
73
|
request_bytes: int | None = None,
|
|
63
74
|
response_bytes: int | None = None,
|
|
75
|
+
request_info=None,
|
|
76
|
+
llm: tuple[str, int, int] | None = None,
|
|
64
77
|
) -> None:
|
|
78
|
+
"""``request_info``: zero-argument callable returning a RequestInfo,
|
|
79
|
+
only called when consumer tracking is on. ``llm``: (model,
|
|
80
|
+
input_tokens, output_tokens) recorded during the request."""
|
|
65
81
|
if self._disabled:
|
|
66
82
|
return
|
|
67
83
|
try:
|
|
84
|
+
if self._openapi_pusher is not None:
|
|
85
|
+
self._openapi_pusher.maybe_push()
|
|
68
86
|
if route in self._ignore_routes:
|
|
69
87
|
return
|
|
70
88
|
if not self._sampler.should_sample():
|
|
71
89
|
return
|
|
72
90
|
event = build_event(
|
|
91
|
+
consumer_id=self._consumer_id(request_info),
|
|
92
|
+
llm=llm,
|
|
73
93
|
service_name=self.config.service_name,
|
|
74
94
|
method=method,
|
|
75
95
|
route=route,
|
|
@@ -89,6 +109,26 @@ class ReqlyClient:
|
|
|
89
109
|
)
|
|
90
110
|
self._disabled = True
|
|
91
111
|
|
|
112
|
+
def _consumer_id(self, request_info) -> str | None:
|
|
113
|
+
if self._consumers is None or request_info is None:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return self._consumers.resolve(request_info)
|
|
117
|
+
except Exception:
|
|
118
|
+
# A failing consumer= callable costs the consumer id, not the event.
|
|
119
|
+
logger.warning("reqly: consumer lookup failed", exc_info=True)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def enable_openapi_push(self, spec_factory) -> None:
|
|
123
|
+
"""Upload ``spec_factory()`` (the app's OpenAPI document) to the
|
|
124
|
+
collector once, when the first request is recorded."""
|
|
125
|
+
self._openapi_pusher = OpenAPIPusher(
|
|
126
|
+
spec_factory=spec_factory,
|
|
127
|
+
collector_url=self.config.collector_url,
|
|
128
|
+
api_key=self.config.api_key,
|
|
129
|
+
service_name=self.config.service_name,
|
|
130
|
+
)
|
|
131
|
+
|
|
92
132
|
def stats(self) -> dict:
|
|
93
133
|
if self._disabled:
|
|
94
134
|
return {"disabled": True}
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
5
6
|
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
6
7
|
|
|
7
8
|
|
|
@@ -86,6 +87,11 @@ class Config:
|
|
|
86
87
|
capture_request_body: bool = False
|
|
87
88
|
release: str | None = None
|
|
88
89
|
environment: str | None = None
|
|
90
|
+
push_openapi: bool = False
|
|
91
|
+
consumer_header: str | None = None
|
|
92
|
+
consumer: Any = None # callable(RequestInfo) -> str | None
|
|
93
|
+
consumer_salt: str | None = None
|
|
94
|
+
hash_consumer: bool = True
|
|
89
95
|
sdk_version: str = field(default_factory=_get_sdk_version)
|
|
90
96
|
|
|
91
97
|
@classmethod
|
|
@@ -102,6 +108,11 @@ class Config:
|
|
|
102
108
|
capture_request_body: bool | None,
|
|
103
109
|
release: str | None = None,
|
|
104
110
|
environment: str | None = None,
|
|
111
|
+
push_openapi: bool | None = None,
|
|
112
|
+
consumer_header: str | None = None,
|
|
113
|
+
consumer: Any = None,
|
|
114
|
+
consumer_salt: str | None = None,
|
|
115
|
+
hash_consumer: bool | None = None,
|
|
105
116
|
) -> "Config":
|
|
106
117
|
import sys as _sys
|
|
107
118
|
|
|
@@ -161,4 +172,17 @@ class Config:
|
|
|
161
172
|
]
|
|
162
173
|
or None
|
|
163
174
|
),
|
|
175
|
+
push_openapi=(
|
|
176
|
+
push_openapi
|
|
177
|
+
if push_openapi is not None
|
|
178
|
+
else _env_bool("REQLY_PUSH_OPENAPI", False)
|
|
179
|
+
),
|
|
180
|
+
consumer_header=consumer_header or os.environ.get("REQLY_CONSUMER_HEADER") or None,
|
|
181
|
+
consumer=consumer,
|
|
182
|
+
consumer_salt=consumer_salt or os.environ.get("REQLY_CONSUMER_SALT") or None,
|
|
183
|
+
hash_consumer=(
|
|
184
|
+
hash_consumer
|
|
185
|
+
if hash_consumer is not None
|
|
186
|
+
else _env_bool("REQLY_HASH_CONSUMER", True)
|
|
187
|
+
),
|
|
164
188
|
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("reqly")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenAPIPusher:
|
|
13
|
+
"""Uploads the app's OpenAPI spec to the collector once per process, so
|
|
14
|
+
Reqly can compare the documented API with the traffic it sees.
|
|
15
|
+
|
|
16
|
+
The upload happens on the first recorded request rather than at
|
|
17
|
+
instrument() time: apps often register routes after instrumenting, and
|
|
18
|
+
by the time requests are served the spec is complete. It runs on a
|
|
19
|
+
daemon thread and every failure is logged, never raised.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
spec_factory: Callable[[], dict],
|
|
26
|
+
collector_url: str,
|
|
27
|
+
api_key: str | None,
|
|
28
|
+
service_name: str,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._spec_factory = spec_factory
|
|
31
|
+
self._url = f"{collector_url.rstrip('/')}/v1/services/{service_name}/openapi"
|
|
32
|
+
self._api_key = api_key
|
|
33
|
+
self._started = False
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
def maybe_push(self) -> None:
|
|
37
|
+
if self._started:
|
|
38
|
+
return
|
|
39
|
+
with self._lock:
|
|
40
|
+
if self._started:
|
|
41
|
+
return
|
|
42
|
+
self._started = True
|
|
43
|
+
threading.Thread(target=self._push, name="reqly-openapi-push", daemon=True).start()
|
|
44
|
+
|
|
45
|
+
def _push(self) -> None:
|
|
46
|
+
try:
|
|
47
|
+
spec = self._spec_factory()
|
|
48
|
+
headers = {"X-Reqly-Key": self._api_key} if self._api_key else {}
|
|
49
|
+
response = httpx.put(self._url, json=spec, headers=headers, timeout=10.0)
|
|
50
|
+
if response.status_code == 200:
|
|
51
|
+
logger.info("reqly: uploaded OpenAPI spec (%s operations)", response.json().get("operations"))
|
|
52
|
+
else:
|
|
53
|
+
logger.warning(
|
|
54
|
+
"reqly: OpenAPI spec upload failed: HTTP %s %s",
|
|
55
|
+
response.status_code, response.text[:200],
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
logger.warning("reqly: OpenAPI spec upload failed", exc_info=True)
|