reqly 0.1.4__tar.gz → 0.2.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.2.0/PKG-INFO +206 -0
- reqly-0.2.0/README.md +168 -0
- {reqly-0.1.4 → reqly-0.2.0}/pyproject.toml +11 -8
- {reqly-0.1.4 → reqly-0.2.0}/reqly/__init__.py +17 -7
- {reqly-0.1.4 → reqly-0.2.0}/reqly/core/buffer.py +22 -1
- {reqly-0.1.4 → reqly-0.2.0}/reqly/core/capture.py +7 -7
- {reqly-0.1.4 → reqly-0.2.0}/reqly/core/client.py +6 -0
- {reqly-0.1.4 → reqly-0.2.0}/reqly/core/config.py +45 -2
- {reqly-0.1.4 → reqly-0.2.0}/reqly/core/sampling.py +7 -4
- {reqly-0.1.4 → reqly-0.2.0}/reqly/core/shipper.py +31 -5
- {reqly-0.1.4 → reqly-0.2.0}/reqly/integrations/fastapi.py +17 -2
- {reqly-0.1.4 → reqly-0.2.0}/reqly/integrations/flask.py +5 -0
- reqly-0.2.0/reqly.egg-info/PKG-INFO +206 -0
- {reqly-0.1.4 → reqly-0.2.0}/reqly.egg-info/SOURCES.txt +4 -1
- {reqly-0.1.4 → reqly-0.2.0}/tests/test_buffer.py +20 -0
- reqly-0.2.0/tests/test_instrument.py +32 -0
- reqly-0.2.0/tests/test_shipper.py +47 -0
- reqly-0.2.0/tests/test_v2_fields.py +111 -0
- reqly-0.1.4/PKG-INFO +0 -137
- reqly-0.1.4/README.md +0 -101
- reqly-0.1.4/reqly.egg-info/PKG-INFO +0 -137
- {reqly-0.1.4 → reqly-0.2.0}/reqly/core/__init__.py +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/reqly/integrations/__init__.py +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/reqly.egg-info/dependency_links.txt +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/reqly.egg-info/requires.txt +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/reqly.egg-info/top_level.txt +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/setup.cfg +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/tests/test_capture.py +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/tests/test_fastapi_integration.py +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/tests/test_flask_integration.py +0 -0
- {reqly-0.1.4 → reqly-0.2.0}/tests/test_sampling.py +0 -0
reqly-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reqly
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Self-hosted API monitoring for FastAPI and Flask in two lines: latency percentiles, error rates and release tracking per route, with deploy-aware alerts and weekly AI anomaly reports.
|
|
5
|
+
Author: Tanish Poddar
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://reqly.tanisheesh.in
|
|
8
|
+
Project-URL: Repository, https://github.com/tanisheesh/reqly
|
|
9
|
+
Project-URL: Issues, https://github.com/tanisheesh/reqly/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/tanisheesh/reqly/blob/main/docs/SETUP.md
|
|
11
|
+
Project-URL: Changelog, https://github.com/tanisheesh/reqly/blob/main/sdk/CHANGELOG.md
|
|
12
|
+
Project-URL: Live Demo, https://reqly-eventflow-dashboard.onrender.com
|
|
13
|
+
Keywords: observability,apm,monitoring,fastapi,flask,telemetry,metrics,latency,percentiles,alerting,anomaly-detection,release-tracking,self-hosted,timescaledb
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Monitoring
|
|
24
|
+
Classifier: Framework :: FastAPI
|
|
25
|
+
Classifier: Framework :: Flask
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
29
|
+
Provides-Extra: fastapi
|
|
30
|
+
Requires-Dist: fastapi>=0.100; extra == "fastapi"
|
|
31
|
+
Provides-Extra: flask
|
|
32
|
+
Requires-Dist: flask>=2.3; extra == "flask"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
35
|
+
Requires-Dist: fastapi>=0.100; extra == "dev"
|
|
36
|
+
Requires-Dist: flask>=2.3; extra == "dev"
|
|
37
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
38
|
+
|
|
39
|
+
# reqly
|
|
40
|
+
|
|
41
|
+
**Self-hosted API monitoring for FastAPI and Flask — two lines of code.**
|
|
42
|
+
Latency percentiles, error rates and release tracking for every route, shipped to your own
|
|
43
|
+
[Reqly](https://github.com/tanisheesh/reqly) collector — which turns them into deploy-aware
|
|
44
|
+
hourly alerts and weekly AI anomaly reports.
|
|
45
|
+
|
|
46
|
+
[](https://pypi.org/project/reqly/)
|
|
47
|
+
[](https://pypi.org/project/reqly/)
|
|
48
|
+
[](https://github.com/tanisheesh/reqly/blob/main/LICENSE)
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install reqly
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
**FastAPI**
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import reqly
|
|
64
|
+
from fastapi import FastAPI
|
|
65
|
+
|
|
66
|
+
app = FastAPI()
|
|
67
|
+
reqly.instrument(
|
|
68
|
+
app,
|
|
69
|
+
service_name="checkout-api",
|
|
70
|
+
collector_url="https://reqly.example.com",
|
|
71
|
+
api_key="your-ingest-key",
|
|
72
|
+
)
|
|
73
|
+
# Every route is now tracked: latency, errors, status codes, release
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Flask**
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import reqly
|
|
80
|
+
from flask import Flask
|
|
81
|
+
|
|
82
|
+
app = Flask(__name__)
|
|
83
|
+
reqly.instrument(app, service_name="checkout-api") # settings from REQLY_* env vars
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`instrument()` detects FastAPI or Flask by itself — no decorators, no middleware to wire up.
|
|
87
|
+
The release you're running is picked up automatically from your CI or host
|
|
88
|
+
(`GITHUB_SHA`, `RENDER_GIT_COMMIT`, `VERCEL_GIT_COMMIT_SHA`, …), so deploys show up in
|
|
89
|
+
Reqly with no extra code.
|
|
90
|
+
|
|
91
|
+
## What you get
|
|
92
|
+
|
|
93
|
+
From the SDK, per request: method, **route template** (`/orders/{id}`, never the raw
|
|
94
|
+
path), status code, duration, error type, host, **release**, environment and
|
|
95
|
+
request/response **body size**.
|
|
96
|
+
|
|
97
|
+
In the Reqly dashboard and collector:
|
|
98
|
+
|
|
99
|
+
- **p50 / p95 / p99 latency** per route and per service — real percentiles from
|
|
100
|
+
mergeable sketches, not the max of per-route numbers
|
|
101
|
+
- **Error rates, status codes and top routes** over 1h / 6h / 24h / 7d
|
|
102
|
+
- **Deploy markers and per-release health** — each release's error rate and p95
|
|
103
|
+
- **Hourly alerts** to Slack, Discord or a webhook when a route breaks from its usual
|
|
104
|
+
weekday-hour pattern, with **root-cause hints**
|
|
105
|
+
- **Weekly AI report** — statistics find the anomalies, Groq (Llama 3.3-70b) writes the
|
|
106
|
+
summary; plain-text fallback without an API key
|
|
107
|
+
|
|
108
|
+
An alert from the demo data looks like this:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
🔴 Anomaly — flask-demo /orders (Friday 15:00-16:00 UTC, z=5.37)
|
|
112
|
+
• error rate 30.0% vs 2.2% usual · p95 6588ms vs 1576ms usual
|
|
113
|
+
• running release v2 — vs v1: errors 2.6% → 33.1%, p95 2072ms → 4501ms
|
|
114
|
+
• 100% of errors came from host pod-3, which served 23% of requests
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Not on Python?** Node, Java, Go and .NET apps can report to the same collector through
|
|
118
|
+
OpenTelemetry — no Reqly SDK needed. See the
|
|
119
|
+
[OpenTelemetry guide](https://github.com/tanisheesh/reqly/blob/main/docs/OTEL.md).
|
|
120
|
+
|
|
121
|
+
## Configuration
|
|
122
|
+
|
|
123
|
+
Every option can be passed to `instrument()` or set as an environment variable.
|
|
124
|
+
Resolution order: **argument → environment variable → default**.
|
|
125
|
+
|
|
126
|
+
| argument | environment variable | default |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `service_name` | `REQLY_SERVICE_NAME` | `sys.argv[0]` basename |
|
|
129
|
+
| `collector_url` | `REQLY_COLLECTOR_URL` | `http://localhost:8000` |
|
|
130
|
+
| `api_key` | `REQLY_API_KEY` | `None` |
|
|
131
|
+
| `release` | `REQLY_RELEASE`, then CI variables (`GITHUB_SHA`, `CI_COMMIT_SHA`, `RENDER_GIT_COMMIT`, `VERCEL_GIT_COMMIT_SHA`, `RAILWAY_GIT_COMMIT_SHA`, `HEROKU_SLUG_COMMIT`, `K_REVISION`, …) | auto-detected, else `None` |
|
|
132
|
+
| `environment` | `REQLY_ENVIRONMENT` | `None` |
|
|
133
|
+
| `sample_rate` | `REQLY_SAMPLE_RATE` | `1.0` |
|
|
134
|
+
| `flush_interval_seconds` | `REQLY_FLUSH_INTERVAL_SECONDS` | `5.0` |
|
|
135
|
+
| `max_batch_size` | `REQLY_MAX_BATCH_SIZE` | `200` |
|
|
136
|
+
| `max_queue_size` | `REQLY_MAX_QUEUE_SIZE` | `2000` |
|
|
137
|
+
| `ignore_routes` | `REQLY_IGNORE_ROUTES` (comma-separated) | `/health,/metrics` |
|
|
138
|
+
| `capture_request_body` | `REQLY_CAPTURE_REQUEST_BODY` | `False` (not implemented yet) |
|
|
139
|
+
|
|
140
|
+
With `sample_rate` below 1.0, request counts in the dashboard are the sampled volume;
|
|
141
|
+
latency percentiles and error rates stay unbiased.
|
|
142
|
+
|
|
143
|
+
## Design guarantees
|
|
144
|
+
|
|
145
|
+
**Fail-open** — any internal SDK error is caught and logged once; instrumentation disables
|
|
146
|
+
itself rather than raise into your app. A slow or unreachable collector never blocks
|
|
147
|
+
request threads — shipping happens on a background thread with strict HTTP timeouts.
|
|
148
|
+
|
|
149
|
+
**Bounded cardinality** — routes are recorded as the framework's matched template
|
|
150
|
+
(`/users/{id}`), never the raw path (`/users/123`). Unmatched paths (404s, scanners)
|
|
151
|
+
collapse into a single `__unmatched__` bucket.
|
|
152
|
+
|
|
153
|
+
**Bounded memory** — events wait in a fixed-size in-memory queue; under backpressure the
|
|
154
|
+
oldest events are dropped and counted instead of growing without limit.
|
|
155
|
+
|
|
156
|
+
**Safe retries** — batches are retried with exponential backoff on `408`, `429` and any
|
|
157
|
+
`5xx` (for example a collector restart behind a proxy); other `4xx` responses are dropped
|
|
158
|
+
immediately. Every event carries a unique `event_id` the collector deduplicates on, so a
|
|
159
|
+
retry never double-counts.
|
|
160
|
+
|
|
161
|
+
**Pre-fork servers** — under gunicorn `--preload` (or uWSGI without lazy-apps) each
|
|
162
|
+
forked worker restarts its own flush thread and HTTP connection pool, so workers' events
|
|
163
|
+
are shipped instead of silently queuing forever.
|
|
164
|
+
|
|
165
|
+
## Compatibility
|
|
166
|
+
|
|
167
|
+
| | Supported |
|
|
168
|
+
|---|---|
|
|
169
|
+
| Python | 3.9 – 3.13 |
|
|
170
|
+
| FastAPI | 0.100+ |
|
|
171
|
+
| Flask | 2.3+ |
|
|
172
|
+
| Collector | any version; `release`, `environment` and body sizes are stored by collector 0.3.0+ and ignored by older ones |
|
|
173
|
+
|
|
174
|
+
## Self-hosting the collector
|
|
175
|
+
|
|
176
|
+
The SDK sends data to a Reqly collector you run. The full stack — collector,
|
|
177
|
+
TimescaleDB and dashboard — starts with Docker Compose:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
git clone https://github.com/tanisheesh/reqly.git
|
|
181
|
+
cd reqly
|
|
182
|
+
docker compose up -d
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Setup, configuration and AWS deployment:
|
|
186
|
+
[docs/SETUP.md](https://github.com/tanisheesh/reqly/blob/main/docs/SETUP.md) ·
|
|
187
|
+
[infra/DEPLOY.md](https://github.com/tanisheesh/reqly/blob/main/infra/DEPLOY.md) ·
|
|
188
|
+
[ingest API spec](https://github.com/tanisheesh/reqly/blob/main/docs/INGEST_SPEC.md)
|
|
189
|
+
|
|
190
|
+
## Live demo
|
|
191
|
+
|
|
192
|
+
Reqly monitors [EventFlow](https://eventflow-g2h5.onrender.com), a Flask event management
|
|
193
|
+
app, in production:
|
|
194
|
+
|
|
195
|
+
- **Demo app** → [eventflow-g2h5.onrender.com](https://eventflow-g2h5.onrender.com)
|
|
196
|
+
- **Metrics dashboard** → [reqly-eventflow-dashboard.onrender.com](https://reqly-eventflow-dashboard.onrender.com)
|
|
197
|
+
|
|
198
|
+
> Log in as **Administrator** (`admin@eventhub.com` / `Admin@123`) → click **Metrics** in the nav.
|
|
199
|
+
|
|
200
|
+
## Changelog
|
|
201
|
+
|
|
202
|
+
See [CHANGELOG.md](https://github.com/tanisheesh/reqly/blob/main/sdk/CHANGELOG.md).
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
GPL-3.0-or-later — see [LICENSE](https://github.com/tanisheesh/reqly/blob/main/LICENSE).
|
reqly-0.2.0/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# reqly
|
|
2
|
+
|
|
3
|
+
**Self-hosted API monitoring for FastAPI and Flask — two lines of code.**
|
|
4
|
+
Latency percentiles, error rates and release tracking for every route, shipped to your own
|
|
5
|
+
[Reqly](https://github.com/tanisheesh/reqly) collector — which turns them into deploy-aware
|
|
6
|
+
hourly alerts and weekly AI anomaly reports.
|
|
7
|
+
|
|
8
|
+
[](https://pypi.org/project/reqly/)
|
|
9
|
+
[](https://pypi.org/project/reqly/)
|
|
10
|
+
[](https://github.com/tanisheesh/reqly/blob/main/LICENSE)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install reqly
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
**FastAPI**
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import reqly
|
|
26
|
+
from fastapi import FastAPI
|
|
27
|
+
|
|
28
|
+
app = FastAPI()
|
|
29
|
+
reqly.instrument(
|
|
30
|
+
app,
|
|
31
|
+
service_name="checkout-api",
|
|
32
|
+
collector_url="https://reqly.example.com",
|
|
33
|
+
api_key="your-ingest-key",
|
|
34
|
+
)
|
|
35
|
+
# Every route is now tracked: latency, errors, status codes, release
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Flask**
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import reqly
|
|
42
|
+
from flask import Flask
|
|
43
|
+
|
|
44
|
+
app = Flask(__name__)
|
|
45
|
+
reqly.instrument(app, service_name="checkout-api") # settings from REQLY_* env vars
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`instrument()` detects FastAPI or Flask by itself — no decorators, no middleware to wire up.
|
|
49
|
+
The release you're running is picked up automatically from your CI or host
|
|
50
|
+
(`GITHUB_SHA`, `RENDER_GIT_COMMIT`, `VERCEL_GIT_COMMIT_SHA`, …), so deploys show up in
|
|
51
|
+
Reqly with no extra code.
|
|
52
|
+
|
|
53
|
+
## What you get
|
|
54
|
+
|
|
55
|
+
From the SDK, per request: method, **route template** (`/orders/{id}`, never the raw
|
|
56
|
+
path), status code, duration, error type, host, **release**, environment and
|
|
57
|
+
request/response **body size**.
|
|
58
|
+
|
|
59
|
+
In the Reqly dashboard and collector:
|
|
60
|
+
|
|
61
|
+
- **p50 / p95 / p99 latency** per route and per service — real percentiles from
|
|
62
|
+
mergeable sketches, not the max of per-route numbers
|
|
63
|
+
- **Error rates, status codes and top routes** over 1h / 6h / 24h / 7d
|
|
64
|
+
- **Deploy markers and per-release health** — each release's error rate and p95
|
|
65
|
+
- **Hourly alerts** to Slack, Discord or a webhook when a route breaks from its usual
|
|
66
|
+
weekday-hour pattern, with **root-cause hints**
|
|
67
|
+
- **Weekly AI report** — statistics find the anomalies, Groq (Llama 3.3-70b) writes the
|
|
68
|
+
summary; plain-text fallback without an API key
|
|
69
|
+
|
|
70
|
+
An alert from the demo data looks like this:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
🔴 Anomaly — flask-demo /orders (Friday 15:00-16:00 UTC, z=5.37)
|
|
74
|
+
• error rate 30.0% vs 2.2% usual · p95 6588ms vs 1576ms usual
|
|
75
|
+
• running release v2 — vs v1: errors 2.6% → 33.1%, p95 2072ms → 4501ms
|
|
76
|
+
• 100% of errors came from host pod-3, which served 23% of requests
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Not on Python?** Node, Java, Go and .NET apps can report to the same collector through
|
|
80
|
+
OpenTelemetry — no Reqly SDK needed. See the
|
|
81
|
+
[OpenTelemetry guide](https://github.com/tanisheesh/reqly/blob/main/docs/OTEL.md).
|
|
82
|
+
|
|
83
|
+
## Configuration
|
|
84
|
+
|
|
85
|
+
Every option can be passed to `instrument()` or set as an environment variable.
|
|
86
|
+
Resolution order: **argument → environment variable → default**.
|
|
87
|
+
|
|
88
|
+
| argument | environment variable | default |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `service_name` | `REQLY_SERVICE_NAME` | `sys.argv[0]` basename |
|
|
91
|
+
| `collector_url` | `REQLY_COLLECTOR_URL` | `http://localhost:8000` |
|
|
92
|
+
| `api_key` | `REQLY_API_KEY` | `None` |
|
|
93
|
+
| `release` | `REQLY_RELEASE`, then CI variables (`GITHUB_SHA`, `CI_COMMIT_SHA`, `RENDER_GIT_COMMIT`, `VERCEL_GIT_COMMIT_SHA`, `RAILWAY_GIT_COMMIT_SHA`, `HEROKU_SLUG_COMMIT`, `K_REVISION`, …) | auto-detected, else `None` |
|
|
94
|
+
| `environment` | `REQLY_ENVIRONMENT` | `None` |
|
|
95
|
+
| `sample_rate` | `REQLY_SAMPLE_RATE` | `1.0` |
|
|
96
|
+
| `flush_interval_seconds` | `REQLY_FLUSH_INTERVAL_SECONDS` | `5.0` |
|
|
97
|
+
| `max_batch_size` | `REQLY_MAX_BATCH_SIZE` | `200` |
|
|
98
|
+
| `max_queue_size` | `REQLY_MAX_QUEUE_SIZE` | `2000` |
|
|
99
|
+
| `ignore_routes` | `REQLY_IGNORE_ROUTES` (comma-separated) | `/health,/metrics` |
|
|
100
|
+
| `capture_request_body` | `REQLY_CAPTURE_REQUEST_BODY` | `False` (not implemented yet) |
|
|
101
|
+
|
|
102
|
+
With `sample_rate` below 1.0, request counts in the dashboard are the sampled volume;
|
|
103
|
+
latency percentiles and error rates stay unbiased.
|
|
104
|
+
|
|
105
|
+
## Design guarantees
|
|
106
|
+
|
|
107
|
+
**Fail-open** — any internal SDK error is caught and logged once; instrumentation disables
|
|
108
|
+
itself rather than raise into your app. A slow or unreachable collector never blocks
|
|
109
|
+
request threads — shipping happens on a background thread with strict HTTP timeouts.
|
|
110
|
+
|
|
111
|
+
**Bounded cardinality** — routes are recorded as the framework's matched template
|
|
112
|
+
(`/users/{id}`), never the raw path (`/users/123`). Unmatched paths (404s, scanners)
|
|
113
|
+
collapse into a single `__unmatched__` bucket.
|
|
114
|
+
|
|
115
|
+
**Bounded memory** — events wait in a fixed-size in-memory queue; under backpressure the
|
|
116
|
+
oldest events are dropped and counted instead of growing without limit.
|
|
117
|
+
|
|
118
|
+
**Safe retries** — batches are retried with exponential backoff on `408`, `429` and any
|
|
119
|
+
`5xx` (for example a collector restart behind a proxy); other `4xx` responses are dropped
|
|
120
|
+
immediately. Every event carries a unique `event_id` the collector deduplicates on, so a
|
|
121
|
+
retry never double-counts.
|
|
122
|
+
|
|
123
|
+
**Pre-fork servers** — under gunicorn `--preload` (or uWSGI without lazy-apps) each
|
|
124
|
+
forked worker restarts its own flush thread and HTTP connection pool, so workers' events
|
|
125
|
+
are shipped instead of silently queuing forever.
|
|
126
|
+
|
|
127
|
+
## Compatibility
|
|
128
|
+
|
|
129
|
+
| | Supported |
|
|
130
|
+
|---|---|
|
|
131
|
+
| Python | 3.9 – 3.13 |
|
|
132
|
+
| FastAPI | 0.100+ |
|
|
133
|
+
| Flask | 2.3+ |
|
|
134
|
+
| Collector | any version; `release`, `environment` and body sizes are stored by collector 0.3.0+ and ignored by older ones |
|
|
135
|
+
|
|
136
|
+
## Self-hosting the collector
|
|
137
|
+
|
|
138
|
+
The SDK sends data to a Reqly collector you run. The full stack — collector,
|
|
139
|
+
TimescaleDB and dashboard — starts with Docker Compose:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
git clone https://github.com/tanisheesh/reqly.git
|
|
143
|
+
cd reqly
|
|
144
|
+
docker compose up -d
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Setup, configuration and AWS deployment:
|
|
148
|
+
[docs/SETUP.md](https://github.com/tanisheesh/reqly/blob/main/docs/SETUP.md) ·
|
|
149
|
+
[infra/DEPLOY.md](https://github.com/tanisheesh/reqly/blob/main/infra/DEPLOY.md) ·
|
|
150
|
+
[ingest API spec](https://github.com/tanisheesh/reqly/blob/main/docs/INGEST_SPEC.md)
|
|
151
|
+
|
|
152
|
+
## Live demo
|
|
153
|
+
|
|
154
|
+
Reqly monitors [EventFlow](https://eventflow-g2h5.onrender.com), a Flask event management
|
|
155
|
+
app, in production:
|
|
156
|
+
|
|
157
|
+
- **Demo app** → [eventflow-g2h5.onrender.com](https://eventflow-g2h5.onrender.com)
|
|
158
|
+
- **Metrics dashboard** → [reqly-eventflow-dashboard.onrender.com](https://reqly-eventflow-dashboard.onrender.com)
|
|
159
|
+
|
|
160
|
+
> Log in as **Administrator** (`admin@eventhub.com` / `Admin@123`) → click **Metrics** in the nav.
|
|
161
|
+
|
|
162
|
+
## Changelog
|
|
163
|
+
|
|
164
|
+
See [CHANGELOG.md](https://github.com/tanisheesh/reqly/blob/main/sdk/CHANGELOG.md).
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
GPL-3.0-or-later — see [LICENSE](https://github.com/tanisheesh/reqly/blob/main/LICENSE).
|
|
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reqly"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Self-hosted API monitoring for FastAPI and Flask 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"
|
|
11
11
|
license = "GPL-3.0-or-later"
|
|
12
|
-
authors = [{ name = "
|
|
12
|
+
authors = [{ name = "Tanish Poddar" }]
|
|
13
13
|
keywords = [
|
|
14
|
-
"observability", "apm", "monitoring", "fastapi", "flask",
|
|
15
|
-
"
|
|
14
|
+
"observability", "apm", "monitoring", "fastapi", "flask", "telemetry",
|
|
15
|
+
"metrics", "latency", "percentiles", "alerting", "anomaly-detection",
|
|
16
|
+
"release-tracking", "self-hosted", "timescaledb"
|
|
16
17
|
]
|
|
17
18
|
classifiers = [
|
|
18
19
|
"Development Status :: 4 - Beta",
|
|
@@ -22,6 +23,7 @@ classifiers = [
|
|
|
22
23
|
"Programming Language :: Python :: 3.10",
|
|
23
24
|
"Programming Language :: Python :: 3.11",
|
|
24
25
|
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
25
27
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
28
|
"Topic :: System :: Monitoring",
|
|
27
29
|
"Framework :: FastAPI",
|
|
@@ -32,11 +34,12 @@ dependencies = [
|
|
|
32
34
|
]
|
|
33
35
|
|
|
34
36
|
[project.urls]
|
|
35
|
-
Homepage = "https://reqly
|
|
37
|
+
Homepage = "https://reqly.tanisheesh.in"
|
|
36
38
|
Repository = "https://github.com/tanisheesh/reqly"
|
|
37
39
|
Issues = "https://github.com/tanisheesh/reqly/issues"
|
|
38
|
-
Documentation = "https://github.com/tanisheesh/reqly/blob/main/
|
|
39
|
-
|
|
40
|
+
Documentation = "https://github.com/tanisheesh/reqly/blob/main/docs/SETUP.md"
|
|
41
|
+
Changelog = "https://github.com/tanisheesh/reqly/blob/main/sdk/CHANGELOG.md"
|
|
42
|
+
"Live Demo" = "https://reqly-eventflow-dashboard.onrender.com"
|
|
40
43
|
|
|
41
44
|
[project.optional-dependencies]
|
|
42
45
|
fastapi = ["fastapi>=0.100"]
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from importlib.metadata import PackageNotFoundError
|
|
5
|
-
from importlib.metadata import version as _pkg_version
|
|
6
4
|
|
|
7
5
|
from .core.client import ReqlyClient
|
|
8
|
-
from .core.config import Config
|
|
6
|
+
from .core.config import Config, _get_sdk_version
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
__version__ = _pkg_version("reqly")
|
|
12
|
-
except PackageNotFoundError:
|
|
13
|
-
__version__ = "0.1.3"
|
|
8
|
+
__version__ = _get_sdk_version()
|
|
14
9
|
|
|
15
10
|
__all__ = ["instrument"]
|
|
16
11
|
|
|
@@ -19,6 +14,7 @@ logger = logging.getLogger("reqly")
|
|
|
19
14
|
# Keep a reference on each instrumented app so repeated calls / shutdown
|
|
20
15
|
# hooks can find the client without the caller having to hold onto it.
|
|
21
16
|
_CLIENTS: dict[int, ReqlyClient] = {}
|
|
17
|
+
_APP_CLIENT_ATTR = "_reqly_client"
|
|
22
18
|
|
|
23
19
|
|
|
24
20
|
def _detect_framework(app) -> str:
|
|
@@ -50,6 +46,8 @@ def instrument(
|
|
|
50
46
|
max_queue_size: int | None = None,
|
|
51
47
|
ignore_routes: list[str] | None = None,
|
|
52
48
|
capture_request_body: bool | None = None,
|
|
49
|
+
release: str | None = None,
|
|
50
|
+
environment: str | None = None,
|
|
53
51
|
) -> ReqlyClient | None:
|
|
54
52
|
"""Instrument a FastAPI or Flask app with one line.
|
|
55
53
|
|
|
@@ -62,6 +60,15 @@ def instrument(
|
|
|
62
60
|
rather than raising, so adding Reqly can never be the reason an
|
|
63
61
|
app fails to start.
|
|
64
62
|
"""
|
|
63
|
+
# Marked on the app object itself: keying on id(app) alone would match a
|
|
64
|
+
# new app that happens to reuse a garbage-collected app's id.
|
|
65
|
+
existing = getattr(app, _APP_CLIENT_ATTR, None)
|
|
66
|
+
if existing is not None:
|
|
67
|
+
# A second call would add a second middleware and a second flush
|
|
68
|
+
# thread, double-counting every request.
|
|
69
|
+
logger.warning("reqly: app is already instrumented, ignoring repeat instrument() call")
|
|
70
|
+
return existing
|
|
71
|
+
|
|
65
72
|
try:
|
|
66
73
|
framework = _detect_framework(app)
|
|
67
74
|
config = Config.resolve(
|
|
@@ -74,6 +81,8 @@ def instrument(
|
|
|
74
81
|
max_queue_size=max_queue_size,
|
|
75
82
|
ignore_routes=ignore_routes,
|
|
76
83
|
capture_request_body=capture_request_body,
|
|
84
|
+
release=release,
|
|
85
|
+
environment=environment,
|
|
77
86
|
)
|
|
78
87
|
client = ReqlyClient(config)
|
|
79
88
|
|
|
@@ -93,6 +102,7 @@ def instrument(
|
|
|
93
102
|
instrument_flask(app, client)
|
|
94
103
|
|
|
95
104
|
_CLIENTS[id(app)] = client
|
|
105
|
+
setattr(app, _APP_CLIENT_ATTR, client)
|
|
96
106
|
return client
|
|
97
107
|
except Exception:
|
|
98
108
|
logger.warning(
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import atexit
|
|
4
4
|
import logging
|
|
5
|
+
import os
|
|
5
6
|
import threading
|
|
6
7
|
import time
|
|
7
8
|
from collections import deque
|
|
@@ -40,11 +41,31 @@ class EventBuffer:
|
|
|
40
41
|
self._dropped_events = 0
|
|
41
42
|
|
|
42
43
|
self._stop_event = threading.Event()
|
|
44
|
+
self._start_thread()
|
|
45
|
+
atexit.register(self.shutdown)
|
|
46
|
+
# Pre-fork servers (gunicorn --preload, uWSGI without lazy-apps)
|
|
47
|
+
# import the app -- and start this thread -- in the master, then
|
|
48
|
+
# fork workers. Threads don't survive fork, so without this hook every
|
|
49
|
+
# worker would queue events forever and never ship them.
|
|
50
|
+
if hasattr(os, "register_at_fork"):
|
|
51
|
+
os.register_at_fork(after_in_child=self._reinit_after_fork)
|
|
52
|
+
|
|
53
|
+
def _start_thread(self) -> None:
|
|
43
54
|
self._thread = threading.Thread(
|
|
44
55
|
target=self._run, name="reqly-flush", daemon=True
|
|
45
56
|
)
|
|
46
57
|
self._thread.start()
|
|
47
|
-
|
|
58
|
+
|
|
59
|
+
def _reinit_after_fork(self) -> None:
|
|
60
|
+
if self._stop_event.is_set():
|
|
61
|
+
return
|
|
62
|
+
# The parent may have held the lock mid-fork; a fresh one is safe
|
|
63
|
+
# because the child is single-threaded at this point. Events queued
|
|
64
|
+
# before the fork belong to the parent, which ships them itself.
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
self._queue.clear()
|
|
67
|
+
self._shipper.reset_after_fork()
|
|
68
|
+
self._start_thread()
|
|
48
69
|
|
|
49
70
|
def add(self, event: RequestEvent) -> None:
|
|
50
71
|
with self._lock:
|
|
@@ -4,14 +4,8 @@ import socket
|
|
|
4
4
|
import uuid
|
|
5
5
|
from dataclasses import asdict, dataclass, field
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
|
-
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
def _get_sdk_version() -> str:
|
|
11
|
-
try:
|
|
12
|
-
return _pkg_version("reqly")
|
|
13
|
-
except PackageNotFoundError:
|
|
14
|
-
return "0.1.0"
|
|
8
|
+
from .config import _get_sdk_version
|
|
15
9
|
|
|
16
10
|
_HOSTNAME = socket.gethostname()
|
|
17
11
|
|
|
@@ -35,6 +29,8 @@ class RequestEvent:
|
|
|
35
29
|
error: bool = False
|
|
36
30
|
error_type: str | None = None
|
|
37
31
|
host: str = _HOSTNAME
|
|
32
|
+
request_bytes: int | None = None
|
|
33
|
+
response_bytes: int | None = None
|
|
38
34
|
sdk_version: str = field(default_factory=_get_sdk_version)
|
|
39
35
|
|
|
40
36
|
def to_dict(self) -> dict:
|
|
@@ -61,6 +57,8 @@ def build_event(
|
|
|
61
57
|
error: bool,
|
|
62
58
|
error_type: str | None,
|
|
63
59
|
sdk_version: str,
|
|
60
|
+
request_bytes: int | None = None,
|
|
61
|
+
response_bytes: int | None = None,
|
|
64
62
|
) -> RequestEvent:
|
|
65
63
|
return RequestEvent(
|
|
66
64
|
service_name=service_name,
|
|
@@ -71,4 +69,6 @@ def build_event(
|
|
|
71
69
|
error=error,
|
|
72
70
|
error_type=error_type,
|
|
73
71
|
sdk_version=sdk_version,
|
|
72
|
+
request_bytes=request_bytes,
|
|
73
|
+
response_bytes=response_bytes,
|
|
74
74
|
)
|
|
@@ -34,6 +34,8 @@ class ReqlyClient:
|
|
|
34
34
|
api_key=config.api_key,
|
|
35
35
|
service_name=config.service_name,
|
|
36
36
|
sdk_version=config.sdk_version,
|
|
37
|
+
release=config.release,
|
|
38
|
+
environment=config.environment,
|
|
37
39
|
)
|
|
38
40
|
self._buffer = EventBuffer(
|
|
39
41
|
shipper=shipper,
|
|
@@ -57,6 +59,8 @@ class ReqlyClient:
|
|
|
57
59
|
duration_ms: float,
|
|
58
60
|
error: bool,
|
|
59
61
|
error_type: str | None,
|
|
62
|
+
request_bytes: int | None = None,
|
|
63
|
+
response_bytes: int | None = None,
|
|
60
64
|
) -> None:
|
|
61
65
|
if self._disabled:
|
|
62
66
|
return
|
|
@@ -74,6 +78,8 @@ class ReqlyClient:
|
|
|
74
78
|
error=error,
|
|
75
79
|
error_type=error_type,
|
|
76
80
|
sdk_version=self.config.sdk_version,
|
|
81
|
+
request_bytes=request_bytes,
|
|
82
|
+
response_bytes=response_bytes,
|
|
77
83
|
)
|
|
78
84
|
self._buffer.add(event)
|
|
79
85
|
except Exception:
|
|
@@ -6,10 +6,13 @@ from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def _get_sdk_version() -> str:
|
|
9
|
+
"""Single source of truth for the SDK version: the installed package
|
|
10
|
+
metadata. A source checkout that was never pip-installed has none, and
|
|
11
|
+
reports that honestly rather than a hardcoded number that goes stale."""
|
|
9
12
|
try:
|
|
10
13
|
return _pkg_version("reqly")
|
|
11
14
|
except PackageNotFoundError:
|
|
12
|
-
return "0.
|
|
15
|
+
return "0.0.0+unknown"
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
def _env_float(name: str, default: float) -> float:
|
|
@@ -39,6 +42,31 @@ def _env_bool(name: str, default: bool) -> bool:
|
|
|
39
42
|
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
40
43
|
|
|
41
44
|
|
|
45
|
+
# Commit SHA variables set by common CI/CD and hosting platforms, checked in
|
|
46
|
+
# order when neither release= nor REQLY_RELEASE is given.
|
|
47
|
+
_RELEASE_ENV_VARS = (
|
|
48
|
+
"GIT_COMMIT", # Jenkins and many custom pipelines
|
|
49
|
+
"GITHUB_SHA", # GitHub Actions
|
|
50
|
+
"CI_COMMIT_SHA", # GitLab CI
|
|
51
|
+
"RENDER_GIT_COMMIT", # Render
|
|
52
|
+
"VERCEL_GIT_COMMIT_SHA", # Vercel
|
|
53
|
+
"RAILWAY_GIT_COMMIT_SHA", # Railway
|
|
54
|
+
"HEROKU_SLUG_COMMIT", # Heroku (dyno metadata)
|
|
55
|
+
"SOURCE_VERSION", # Heroku buildpacks
|
|
56
|
+
"K_REVISION", # Cloud Run / Knative revision name
|
|
57
|
+
)
|
|
58
|
+
_MAX_RELEASE_LEN = 128
|
|
59
|
+
_MAX_ENVIRONMENT_LEN = 32
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _detect_release() -> str | None:
|
|
63
|
+
for name in ("REQLY_RELEASE",) + _RELEASE_ENV_VARS:
|
|
64
|
+
value = (os.environ.get(name) or "").strip()
|
|
65
|
+
if value:
|
|
66
|
+
return value[:_MAX_RELEASE_LEN]
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
42
70
|
@dataclass
|
|
43
71
|
class Config:
|
|
44
72
|
"""Resolved configuration for an instrumented app.
|
|
@@ -56,6 +84,8 @@ class Config:
|
|
|
56
84
|
max_queue_size: int = 2000
|
|
57
85
|
ignore_routes: list[str] = field(default_factory=lambda: ["/health", "/metrics"])
|
|
58
86
|
capture_request_body: bool = False
|
|
87
|
+
release: str | None = None
|
|
88
|
+
environment: str | None = None
|
|
59
89
|
sdk_version: str = field(default_factory=_get_sdk_version)
|
|
60
90
|
|
|
61
91
|
@classmethod
|
|
@@ -70,6 +100,8 @@ class Config:
|
|
|
70
100
|
max_queue_size: int | None,
|
|
71
101
|
ignore_routes: list[str] | None,
|
|
72
102
|
capture_request_body: bool | None,
|
|
103
|
+
release: str | None = None,
|
|
104
|
+
environment: str | None = None,
|
|
73
105
|
) -> "Config":
|
|
74
106
|
import sys as _sys
|
|
75
107
|
|
|
@@ -111,11 +143,22 @@ class Config:
|
|
|
111
143
|
ignore_routes=(
|
|
112
144
|
ignore_routes
|
|
113
145
|
if ignore_routes is not None
|
|
114
|
-
else
|
|
146
|
+
else [
|
|
147
|
+
r.strip()
|
|
148
|
+
for r in os.environ.get("REQLY_IGNORE_ROUTES", "/health,/metrics").split(",")
|
|
149
|
+
if r.strip()
|
|
150
|
+
]
|
|
115
151
|
),
|
|
116
152
|
capture_request_body=(
|
|
117
153
|
capture_request_body
|
|
118
154
|
if capture_request_body is not None
|
|
119
155
|
else _env_bool("REQLY_CAPTURE_REQUEST_BODY", False)
|
|
120
156
|
),
|
|
157
|
+
release=(release[:_MAX_RELEASE_LEN] if release else _detect_release()),
|
|
158
|
+
environment=(
|
|
159
|
+
(environment or os.environ.get("REQLY_ENVIRONMENT") or "").strip()[
|
|
160
|
+
:_MAX_ENVIRONMENT_LEN
|
|
161
|
+
]
|
|
162
|
+
or None
|
|
163
|
+
),
|
|
121
164
|
)
|