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.
Files changed (31) hide show
  1. reqly-0.2.0/PKG-INFO +206 -0
  2. reqly-0.2.0/README.md +168 -0
  3. {reqly-0.1.4 → reqly-0.2.0}/pyproject.toml +11 -8
  4. {reqly-0.1.4 → reqly-0.2.0}/reqly/__init__.py +17 -7
  5. {reqly-0.1.4 → reqly-0.2.0}/reqly/core/buffer.py +22 -1
  6. {reqly-0.1.4 → reqly-0.2.0}/reqly/core/capture.py +7 -7
  7. {reqly-0.1.4 → reqly-0.2.0}/reqly/core/client.py +6 -0
  8. {reqly-0.1.4 → reqly-0.2.0}/reqly/core/config.py +45 -2
  9. {reqly-0.1.4 → reqly-0.2.0}/reqly/core/sampling.py +7 -4
  10. {reqly-0.1.4 → reqly-0.2.0}/reqly/core/shipper.py +31 -5
  11. {reqly-0.1.4 → reqly-0.2.0}/reqly/integrations/fastapi.py +17 -2
  12. {reqly-0.1.4 → reqly-0.2.0}/reqly/integrations/flask.py +5 -0
  13. reqly-0.2.0/reqly.egg-info/PKG-INFO +206 -0
  14. {reqly-0.1.4 → reqly-0.2.0}/reqly.egg-info/SOURCES.txt +4 -1
  15. {reqly-0.1.4 → reqly-0.2.0}/tests/test_buffer.py +20 -0
  16. reqly-0.2.0/tests/test_instrument.py +32 -0
  17. reqly-0.2.0/tests/test_shipper.py +47 -0
  18. reqly-0.2.0/tests/test_v2_fields.py +111 -0
  19. reqly-0.1.4/PKG-INFO +0 -137
  20. reqly-0.1.4/README.md +0 -101
  21. reqly-0.1.4/reqly.egg-info/PKG-INFO +0 -137
  22. {reqly-0.1.4 → reqly-0.2.0}/reqly/core/__init__.py +0 -0
  23. {reqly-0.1.4 → reqly-0.2.0}/reqly/integrations/__init__.py +0 -0
  24. {reqly-0.1.4 → reqly-0.2.0}/reqly.egg-info/dependency_links.txt +0 -0
  25. {reqly-0.1.4 → reqly-0.2.0}/reqly.egg-info/requires.txt +0 -0
  26. {reqly-0.1.4 → reqly-0.2.0}/reqly.egg-info/top_level.txt +0 -0
  27. {reqly-0.1.4 → reqly-0.2.0}/setup.cfg +0 -0
  28. {reqly-0.1.4 → reqly-0.2.0}/tests/test_capture.py +0 -0
  29. {reqly-0.1.4 → reqly-0.2.0}/tests/test_fastapi_integration.py +0 -0
  30. {reqly-0.1.4 → reqly-0.2.0}/tests/test_flask_integration.py +0 -0
  31. {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
+ [![PyPI](https://img.shields.io/pypi/v/reqly?color=06b6d4&label=reqly)](https://pypi.org/project/reqly/)
47
+ [![Python](https://img.shields.io/pypi/pyversions/reqly?color=06b6d4)](https://pypi.org/project/reqly/)
48
+ [![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-06b6d4)](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
+ [![PyPI](https://img.shields.io/pypi/v/reqly?color=06b6d4&label=reqly)](https://pypi.org/project/reqly/)
9
+ [![Python](https://img.shields.io/pypi/pyversions/reqly?color=06b6d4)](https://pypi.org/project/reqly/)
10
+ [![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-06b6d4)](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.1.4"
8
- description = "Auto-instrumentation SDK for FastAPI and Flask: zero-code latency, error rates, and status codes shipped to a Reqly collector."
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 = "reqly" }]
12
+ authors = [{ name = "Tanish Poddar" }]
13
13
  keywords = [
14
- "observability", "apm", "monitoring", "fastapi", "flask",
15
- "telemetry", "metrics", "latency", "tracing"
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-sdk.vercel.app/"
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/CONTRIBUTING.md"
39
- "Live Demo" = "https://eventflow-g2h5.onrender.com"
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
- try:
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
- atexit.register(self.shutdown)
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.1.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 (os.environ.get("REQLY_IGNORE_ROUTES", "/health,/metrics").split(","))
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
  )