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.
Files changed (37) hide show
  1. {reqly-0.3.0 → reqly-0.5.0}/PKG-INFO +42 -3
  2. {reqly-0.3.0 → reqly-0.5.0}/README.md +41 -2
  3. {reqly-0.3.0 → reqly-0.5.0}/pyproject.toml +1 -1
  4. {reqly-0.3.0 → reqly-0.5.0}/reqly/__init__.py +101 -2
  5. {reqly-0.3.0 → reqly-0.5.0}/reqly/core/capture.py +11 -0
  6. {reqly-0.3.0 → reqly-0.5.0}/reqly/core/client.py +40 -0
  7. {reqly-0.3.0 → reqly-0.5.0}/reqly/core/config.py +24 -0
  8. reqly-0.5.0/reqly/core/openapi_push.py +58 -0
  9. reqly-0.5.0/reqly/core/request_context.py +167 -0
  10. {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/django.py +22 -6
  11. {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/fastapi.py +13 -0
  12. {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/flask.py +11 -0
  13. reqly-0.5.0/reqly/integrations/wsgi.py +157 -0
  14. {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/PKG-INFO +42 -3
  15. {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/SOURCES.txt +5 -0
  16. reqly-0.5.0/tests/test_consumers_llm_wsgi.py +239 -0
  17. {reqly-0.3.0 → reqly-0.5.0}/tests/test_more_frameworks.py +20 -0
  18. reqly-0.5.0/tests/test_openapi_push.py +170 -0
  19. {reqly-0.3.0 → reqly-0.5.0}/reqly/core/__init__.py +0 -0
  20. {reqly-0.3.0 → reqly-0.5.0}/reqly/core/buffer.py +0 -0
  21. {reqly-0.3.0 → reqly-0.5.0}/reqly/core/sampling.py +0 -0
  22. {reqly-0.3.0 → reqly-0.5.0}/reqly/core/shipper.py +0 -0
  23. {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/__init__.py +0 -0
  24. {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/litestar.py +0 -0
  25. {reqly-0.3.0 → reqly-0.5.0}/reqly/integrations/starlette.py +0 -0
  26. {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/dependency_links.txt +0 -0
  27. {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/requires.txt +0 -0
  28. {reqly-0.3.0 → reqly-0.5.0}/reqly.egg-info/top_level.txt +0 -0
  29. {reqly-0.3.0 → reqly-0.5.0}/setup.cfg +0 -0
  30. {reqly-0.3.0 → reqly-0.5.0}/tests/test_buffer.py +0 -0
  31. {reqly-0.3.0 → reqly-0.5.0}/tests/test_capture.py +0 -0
  32. {reqly-0.3.0 → reqly-0.5.0}/tests/test_fastapi_integration.py +0 -0
  33. {reqly-0.3.0 → reqly-0.5.0}/tests/test_flask_integration.py +0 -0
  34. {reqly-0.3.0 → reqly-0.5.0}/tests/test_instrument.py +0 -0
  35. {reqly-0.3.0 → reqly-0.5.0}/tests/test_sampling.py +0 -0
  36. {reqly-0.3.0 → reqly-0.5.0}/tests/test_shipper.py +0 -0
  37. {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.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
- - **Weekly AI report** — statistics find the anomalies, Groq (Llama 3.3-70b) writes the
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
- | Collector | any version; `release`, `environment` and body sizes are stored by collector 0.3.0+ and ignored by older ones |
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
- - **Weekly AI report** — statistics find the anomalies, Groq (Llama 3.3-70b) writes the
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
- | Collector | any version; `release`, `environment` and body sizes are stored by collector 0.3.0+ and ignored by older ones |
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.3.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__ = ["instrument"]
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)