reqly 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from .core.client import ReqlyClient
6
+ from .core.config import Config
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = ["instrument"]
10
+
11
+ logger = logging.getLogger("reqly")
12
+
13
+ # Keep a reference on each instrumented app so repeated calls / shutdown
14
+ # hooks can find the client without the caller having to hold onto it.
15
+ _CLIENTS: dict[int, ReqlyClient] = {}
16
+
17
+
18
+ def _detect_framework(app) -> str:
19
+ module = type(app).__module__ or ""
20
+ if "flask" in module:
21
+ return "flask"
22
+ if "fastapi" in module or "starlette" in module:
23
+ return "fastapi"
24
+ # Fallback to duck typing if the module name isn't conclusive.
25
+ if hasattr(app, "add_middleware"):
26
+ return "fastapi"
27
+ if hasattr(app, "before_request") and hasattr(app, "wsgi_app"):
28
+ return "flask"
29
+ raise TypeError(
30
+ "reqly.instrument(): could not detect framework for app of type "
31
+ f"{type(app)!r}. Supported: FastAPI, Flask."
32
+ )
33
+
34
+
35
+ def instrument(
36
+ app,
37
+ *,
38
+ service_name: str | None = None,
39
+ collector_url: str | None = None,
40
+ api_key: str | None = None,
41
+ sample_rate: float | None = None,
42
+ flush_interval_seconds: float | None = None,
43
+ max_batch_size: int | None = None,
44
+ max_queue_size: int | None = None,
45
+ ignore_routes: list[str] | None = None,
46
+ capture_request_body: bool | None = None,
47
+ ) -> ReqlyClient:
48
+ """Instrument a FastAPI or Flask app with one line.
49
+
50
+ Config resolution order for any omitted argument: explicit kwarg >
51
+ environment variable (REQLY_*) > default. See core.config.Config
52
+ for the full list of environment variables.
53
+
54
+ This function itself is guarded: a failure to detect the framework or
55
+ initialize the client is logged and the app is returned uninstrumented
56
+ rather than raising, so adding Reqly can never be the reason an
57
+ app fails to start.
58
+ """
59
+ try:
60
+ framework = _detect_framework(app)
61
+ config = Config.resolve(
62
+ service_name=service_name,
63
+ collector_url=collector_url,
64
+ api_key=api_key,
65
+ sample_rate=sample_rate,
66
+ flush_interval_seconds=flush_interval_seconds,
67
+ max_batch_size=max_batch_size,
68
+ max_queue_size=max_queue_size,
69
+ ignore_routes=ignore_routes,
70
+ capture_request_body=capture_request_body,
71
+ )
72
+ client = ReqlyClient(config)
73
+
74
+ if framework == "fastapi":
75
+ from .integrations.fastapi import instrument_fastapi
76
+
77
+ instrument_fastapi(app, client)
78
+ else:
79
+ from .integrations.flask import instrument_flask
80
+
81
+ instrument_flask(app, client)
82
+
83
+ _CLIENTS[id(app)] = client
84
+ return client
85
+ except Exception:
86
+ logger.warning(
87
+ "reqly: instrument() failed, app will run uninstrumented",
88
+ exc_info=True,
89
+ )
90
+ return None
reqly/core/__init__.py ADDED
File without changes
reqly/core/buffer.py ADDED
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import logging
5
+ import threading
6
+ import time
7
+ from collections import deque
8
+
9
+ from .capture import RequestEvent
10
+ from .shipper import Shipper
11
+
12
+ logger = logging.getLogger("reqly")
13
+
14
+
15
+ class EventBuffer:
16
+ """Bounded in-memory queue + background flush thread.
17
+
18
+ A single daemon thread mechanism is used for both sync (Flask) and async
19
+ (FastAPI) hosts so the core stays identical regardless of framework --
20
+ deliberately simple over clever (no asyncio.create_task path for FastAPI).
21
+
22
+ Backpressure: if the queue is full when a new event arrives, the oldest
23
+ entry is dropped and a counter incremented. The calling request thread
24
+ never blocks waiting for queue space.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ shipper: Shipper,
30
+ max_queue_size: int = 2000,
31
+ max_batch_size: int = 200,
32
+ flush_interval_seconds: float = 5.0,
33
+ ) -> None:
34
+ self._shipper = shipper
35
+ self._max_batch_size = max_batch_size
36
+ self._flush_interval_seconds = flush_interval_seconds
37
+
38
+ self._queue: deque[RequestEvent] = deque(maxlen=max_queue_size)
39
+ self._lock = threading.Lock()
40
+ self._dropped_events = 0
41
+
42
+ self._stop_event = threading.Event()
43
+ self._thread = threading.Thread(
44
+ target=self._run, name="reqly-flush", daemon=True
45
+ )
46
+ self._thread.start()
47
+ atexit.register(self.shutdown)
48
+
49
+ def add(self, event: RequestEvent) -> None:
50
+ with self._lock:
51
+ if len(self._queue) >= self._queue.maxlen:
52
+ self._dropped_events += 1
53
+ self._queue.append(event)
54
+
55
+ def _drain_batch(self) -> list[RequestEvent]:
56
+ with self._lock:
57
+ batch = []
58
+ while self._queue and len(batch) < self._max_batch_size:
59
+ batch.append(self._queue.popleft())
60
+ return batch
61
+
62
+ def _run(self) -> None:
63
+ while not self._stop_event.is_set():
64
+ self._stop_event.wait(self._flush_interval_seconds)
65
+ self.flush()
66
+
67
+ def flush(self) -> None:
68
+ try:
69
+ while True:
70
+ batch = self._drain_batch()
71
+ if not batch:
72
+ return
73
+ self._shipper.send_batch(batch)
74
+ if len(batch) < self._max_batch_size:
75
+ return
76
+ except Exception:
77
+ logger.warning("reqly: unexpected error during flush", exc_info=True)
78
+
79
+ def shutdown(self) -> None:
80
+ if self._stop_event.is_set():
81
+ return
82
+ self._stop_event.set()
83
+ try:
84
+ self.flush()
85
+ finally:
86
+ self._shipper.close()
87
+
88
+ def stats(self) -> dict:
89
+ with self._lock:
90
+ return {
91
+ "queued_events": len(self._queue),
92
+ "dropped_events": self._dropped_events,
93
+ "shipped_events": self._shipper.shipped_events,
94
+ "dropped_batches": self._shipper.dropped_batches,
95
+ }
reqly/core/capture.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import uuid
5
+ from dataclasses import asdict, dataclass, field
6
+ from datetime import datetime, timezone
7
+
8
+ _HOSTNAME = socket.gethostname()
9
+
10
+
11
+ @dataclass
12
+ class RequestEvent:
13
+ """One captured request. ``route`` MUST be the normalized route template
14
+ (e.g. "/users/{id}"), never the raw path (e.g. "/users/123") -- every
15
+ downstream aggregate's cardinality depends on this.
16
+ """
17
+
18
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
19
+ service_name: str = ""
20
+ timestamp: str = field(
21
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
22
+ )
23
+ method: str = "GET"
24
+ route: str = "/"
25
+ status_code: int = 200
26
+ duration_ms: float = 0.0
27
+ error: bool = False
28
+ error_type: str | None = None
29
+ host: str = _HOSTNAME
30
+ sdk_version: str = "0.1.0"
31
+
32
+ def to_dict(self) -> dict:
33
+ return asdict(self)
34
+
35
+
36
+ def normalize_route(raw_path: str, matched_template: str | None) -> str:
37
+ """Return the route template if one was matched by the framework's
38
+ router; otherwise collapse to a single bucket so unmatched paths
39
+ (404s, scanners hitting random URLs) can't blow up cardinality.
40
+ """
41
+ if matched_template:
42
+ return matched_template
43
+ return "__unmatched__"
44
+
45
+
46
+ def build_event(
47
+ *,
48
+ service_name: str,
49
+ method: str,
50
+ route: str,
51
+ status_code: int,
52
+ duration_ms: float,
53
+ error: bool,
54
+ error_type: str | None,
55
+ sdk_version: str,
56
+ ) -> RequestEvent:
57
+ return RequestEvent(
58
+ service_name=service_name,
59
+ method=method,
60
+ route=route,
61
+ status_code=status_code,
62
+ duration_ms=duration_ms,
63
+ error=error,
64
+ error_type=error_type,
65
+ sdk_version=sdk_version,
66
+ )
reqly/core/client.py ADDED
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from .buffer import EventBuffer
6
+ from .capture import build_event
7
+ from .config import Config
8
+ from .sampling import Sampler
9
+ from .shipper import Shipper
10
+
11
+ logger = logging.getLogger("reqly")
12
+
13
+
14
+ class ReqlyClient:
15
+ """Wires config + sampler + shipper + buffer together for one
16
+ instrumented app, and enforces the SDK's core non-functional guarantee:
17
+ instrumentation errors must NEVER propagate into the host application.
18
+
19
+ Every public method here is internally guarded -- on the first internal
20
+ failure, instrumentation disables itself (logs once, then becomes a
21
+ no-op) rather than risk repeatedly raising into the host app's request
22
+ path.
23
+ """
24
+
25
+ def __init__(self, config: Config) -> None:
26
+ self.config = config
27
+ self._disabled = False
28
+ self._ignore_routes = set(config.ignore_routes)
29
+
30
+ try:
31
+ self._sampler = Sampler(config.sample_rate)
32
+ shipper = Shipper(
33
+ collector_url=config.collector_url,
34
+ api_key=config.api_key,
35
+ service_name=config.service_name,
36
+ sdk_version=config.sdk_version,
37
+ )
38
+ self._buffer = EventBuffer(
39
+ shipper=shipper,
40
+ max_queue_size=config.max_queue_size,
41
+ max_batch_size=config.max_batch_size,
42
+ flush_interval_seconds=config.flush_interval_seconds,
43
+ )
44
+ except Exception:
45
+ logger.warning(
46
+ "reqly: failed to initialize, instrumentation disabled",
47
+ exc_info=True,
48
+ )
49
+ self._disabled = True
50
+
51
+ def record_request(
52
+ self,
53
+ *,
54
+ method: str,
55
+ route: str,
56
+ status_code: int,
57
+ duration_ms: float,
58
+ error: bool,
59
+ error_type: str | None,
60
+ ) -> None:
61
+ if self._disabled:
62
+ return
63
+ try:
64
+ if route in self._ignore_routes:
65
+ return
66
+ if not self._sampler.should_sample():
67
+ return
68
+ event = build_event(
69
+ service_name=self.config.service_name,
70
+ method=method,
71
+ route=route,
72
+ status_code=status_code,
73
+ duration_ms=duration_ms,
74
+ error=error,
75
+ error_type=error_type,
76
+ sdk_version=self.config.sdk_version,
77
+ )
78
+ self._buffer.add(event)
79
+ except Exception:
80
+ logger.warning(
81
+ "reqly: internal error, disabling instrumentation",
82
+ exc_info=True,
83
+ )
84
+ self._disabled = True
85
+
86
+ def stats(self) -> dict:
87
+ if self._disabled:
88
+ return {"disabled": True}
89
+ return {"disabled": False, **self._buffer.stats(), **self._sampler.stats()}
90
+
91
+ def shutdown(self) -> None:
92
+ if self._disabled:
93
+ return
94
+ try:
95
+ self._buffer.shutdown()
96
+ except Exception:
97
+ pass
reqly/core/config.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ def _env_float(name: str, default: float) -> float:
8
+ raw = os.environ.get(name)
9
+ if raw is None or raw == "":
10
+ return default
11
+ try:
12
+ return float(raw)
13
+ except ValueError:
14
+ return default
15
+
16
+
17
+ def _env_int(name: str, default: int) -> int:
18
+ raw = os.environ.get(name)
19
+ if raw is None or raw == "":
20
+ return default
21
+ try:
22
+ return int(raw)
23
+ except ValueError:
24
+ return default
25
+
26
+
27
+ def _env_bool(name: str, default: bool) -> bool:
28
+ raw = os.environ.get(name)
29
+ if raw is None or raw == "":
30
+ return default
31
+ return raw.strip().lower() in ("1", "true", "yes", "on")
32
+
33
+
34
+ @dataclass
35
+ class Config:
36
+ """Resolved configuration for an instrumented app.
37
+
38
+ Resolution order for any field passed as None to ``instrument()``:
39
+ explicit kwarg > environment variable > default.
40
+ """
41
+
42
+ service_name: str
43
+ collector_url: str = "http://localhost:8000"
44
+ api_key: str | None = None
45
+ sample_rate: float = 1.0
46
+ flush_interval_seconds: float = 5.0
47
+ max_batch_size: int = 200
48
+ max_queue_size: int = 2000
49
+ ignore_routes: list[str] = field(default_factory=lambda: ["/health", "/metrics"])
50
+ capture_request_body: bool = False
51
+ sdk_version: str = "0.1.0"
52
+
53
+ @classmethod
54
+ def resolve(
55
+ cls,
56
+ service_name: str | None,
57
+ collector_url: str | None,
58
+ api_key: str | None,
59
+ sample_rate: float | None,
60
+ flush_interval_seconds: float | None,
61
+ max_batch_size: int | None,
62
+ max_queue_size: int | None,
63
+ ignore_routes: list[str] | None,
64
+ capture_request_body: bool | None,
65
+ ) -> "Config":
66
+ import sys as _sys
67
+
68
+ resolved_service_name = (
69
+ service_name
70
+ or os.environ.get("REQLY_SERVICE_NAME")
71
+ or (os.path.basename(_sys.argv[0]) if _sys.argv and _sys.argv[0] else None)
72
+ or "unnamed-service"
73
+ )
74
+
75
+ return cls(
76
+ service_name=resolved_service_name,
77
+ collector_url=(
78
+ collector_url
79
+ or os.environ.get("REQLY_COLLECTOR_URL")
80
+ or "http://localhost:8000"
81
+ ),
82
+ api_key=api_key or os.environ.get("REQLY_API_KEY"),
83
+ sample_rate=(
84
+ sample_rate
85
+ if sample_rate is not None
86
+ else _env_float("REQLY_SAMPLE_RATE", 1.0)
87
+ ),
88
+ flush_interval_seconds=(
89
+ flush_interval_seconds
90
+ if flush_interval_seconds is not None
91
+ else _env_float("REQLY_FLUSH_INTERVAL_SECONDS", 5.0)
92
+ ),
93
+ max_batch_size=(
94
+ max_batch_size
95
+ if max_batch_size is not None
96
+ else _env_int("REQLY_MAX_BATCH_SIZE", 200)
97
+ ),
98
+ max_queue_size=(
99
+ max_queue_size
100
+ if max_queue_size is not None
101
+ else _env_int("REQLY_MAX_QUEUE_SIZE", 2000)
102
+ ),
103
+ ignore_routes=(
104
+ ignore_routes
105
+ if ignore_routes is not None
106
+ else (os.environ.get("REQLY_IGNORE_ROUTES", "/health,/metrics").split(","))
107
+ ),
108
+ capture_request_body=(
109
+ capture_request_body
110
+ if capture_request_body is not None
111
+ else _env_bool("REQLY_CAPTURE_REQUEST_BODY", False)
112
+ ),
113
+ )
reqly/core/sampling.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import threading
5
+
6
+
7
+ class Sampler:
8
+ """Decides whether a given request should be kept.
9
+
10
+ Unsampled requests still increment ``observed_count`` so that volume and
11
+ rate metrics can be corrected downstream by ``1 / sample_rate``. Default
12
+ sample_rate is 1.0 (no sampling) -- this exists as a documented config
13
+ knob, not because this project's traffic needs it.
14
+ """
15
+
16
+ def __init__(self, sample_rate: float = 1.0) -> None:
17
+ self.sample_rate = max(0.0, min(1.0, sample_rate))
18
+ self._lock = threading.Lock()
19
+ self.observed_count = 0
20
+ self.sampled_count = 0
21
+
22
+ def should_sample(self) -> bool:
23
+ with self._lock:
24
+ self.observed_count += 1
25
+ if self.sample_rate >= 1.0:
26
+ self.sampled_count += 1
27
+ return True
28
+ keep = random.random() < self.sample_rate
29
+ if keep:
30
+ self.sampled_count += 1
31
+ return keep
32
+
33
+ def stats(self) -> dict:
34
+ with self._lock:
35
+ return {
36
+ "observed_count": self.observed_count,
37
+ "sampled_count": self.sampled_count,
38
+ "sample_rate": self.sample_rate,
39
+ }
reqly/core/shipper.py ADDED
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import random
5
+ import time
6
+
7
+ import httpx
8
+
9
+ from .capture import RequestEvent
10
+
11
+ logger = logging.getLogger("reqly")
12
+
13
+ _MAX_RETRIES = 3
14
+ _BACKOFF_BASE_SECONDS = 0.5
15
+
16
+
17
+ class Shipper:
18
+ """Ships batches of events to the collector over HTTP.
19
+
20
+ Built on httpx with explicit per-phase timeouts so a slow or unreachable
21
+ collector can NEVER block the host application's request path -- this
22
+ client is only ever used from the buffer's background flush thread, never
23
+ inline with a request.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ collector_url: str,
29
+ api_key: str | None,
30
+ service_name: str,
31
+ sdk_version: str,
32
+ ) -> None:
33
+ self._service_name = service_name
34
+ self._sdk_version = sdk_version
35
+ self.dropped_batches = 0
36
+ self.shipped_events = 0
37
+
38
+ headers = {"Content-Type": "application/json"}
39
+ if api_key:
40
+ headers["X-Reqly-Key"] = api_key
41
+
42
+ self._client = httpx.Client(
43
+ base_url=collector_url.rstrip("/"),
44
+ headers=headers,
45
+ timeout=httpx.Timeout(connect=1.0, read=2.0, write=2.0, pool=1.0),
46
+ http2=False,
47
+ )
48
+
49
+ def send_batch(self, events: list[RequestEvent]) -> bool:
50
+ if not events:
51
+ return True
52
+
53
+ payload = {
54
+ "service_name": self._service_name,
55
+ "sdk_version": self._sdk_version,
56
+ "events": [e.to_dict() for e in events],
57
+ }
58
+
59
+ for attempt in range(_MAX_RETRIES):
60
+ try:
61
+ response = self._client.post("/v1/ingest", json=payload)
62
+ if response.status_code < 500:
63
+ self.shipped_events += len(events)
64
+ return True
65
+ logger.debug(
66
+ "reqly: collector returned %s, attempt %d/%d",
67
+ response.status_code,
68
+ attempt + 1,
69
+ _MAX_RETRIES,
70
+ )
71
+ except httpx.HTTPError as exc:
72
+ logger.debug(
73
+ "reqly: shipper error %s, attempt %d/%d",
74
+ exc,
75
+ attempt + 1,
76
+ _MAX_RETRIES,
77
+ )
78
+
79
+ if attempt < _MAX_RETRIES - 1:
80
+ backoff = _BACKOFF_BASE_SECONDS * (2**attempt)
81
+ time.sleep(backoff + random.uniform(0, 0.1))
82
+
83
+ self.dropped_batches += 1
84
+ logger.warning(
85
+ "reqly: dropped a batch of %d events after %d retries",
86
+ len(events),
87
+ _MAX_RETRIES,
88
+ )
89
+ return False
90
+
91
+ def close(self) -> None:
92
+ try:
93
+ self._client.close()
94
+ except Exception:
95
+ pass
File without changes
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from ..core.capture import normalize_route
6
+ from ..core.client import ReqlyClient
7
+
8
+
9
+ class ReqlyASGIMiddleware:
10
+ """Pure ASGI middleware (not Starlette's BaseHTTPMiddleware, which
11
+ buffers streaming response bodies). Wraps ``send`` to intercept the
12
+ ``http.response.start`` message for the status code, and measures
13
+ duration at the point the response completes.
14
+
15
+ The route template is only known AFTER the inner app has routed the
16
+ request (Starlette sets ``scope["route"]`` during dispatch), so the
17
+ timer starts before calling the inner app and the route is read from
18
+ the (mutated in place) scope dict afterward.
19
+ """
20
+
21
+ def __init__(self, app, client: ReqlyClient) -> None:
22
+ self.app = app
23
+ self._client = client
24
+
25
+ async def __call__(self, scope, receive, send):
26
+ if scope["type"] != "http":
27
+ await self.app(scope, receive, send)
28
+ return
29
+
30
+ start = time.perf_counter()
31
+ status_code = 500
32
+ error = False
33
+ error_type = None
34
+
35
+ async def send_wrapper(message):
36
+ nonlocal status_code
37
+ if message["type"] == "http.response.start":
38
+ status_code = message["status"]
39
+ await send(message)
40
+
41
+ try:
42
+ await self.app(scope, receive, send_wrapper)
43
+ except Exception as exc:
44
+ error = True
45
+ error_type = type(exc).__name__
46
+ status_code = 500
47
+ raise
48
+ finally:
49
+ duration_ms = (time.perf_counter() - start) * 1000
50
+ route = scope.get("route")
51
+ route_template = getattr(route, "path", None) if route else None
52
+ normalized = normalize_route(scope.get("path", "/"), route_template)
53
+ self._client.record_request(
54
+ method=scope.get("method", "GET"),
55
+ route=normalized,
56
+ status_code=status_code,
57
+ duration_ms=duration_ms,
58
+ error=error or status_code >= 500,
59
+ error_type=error_type,
60
+ )
61
+
62
+
63
+ def instrument_fastapi(app, client: ReqlyClient) -> None:
64
+ app.add_middleware(ReqlyASGIMiddleware, client=client)
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from ..core.capture import normalize_route
6
+ from ..core.client import ReqlyClient
7
+
8
+ _START_TIME_ATTR = "_REQLY_start_time"
9
+
10
+
11
+ def instrument_flask(app, client: ReqlyClient) -> None:
12
+ """Flask is WSGI, not ASGI -- uses request lifecycle signals rather than
13
+ wrapping ``app.wsgi_app`` directly. ``teardown_request`` matters
14
+ specifically because it fires even on unhandled exceptions, which is
15
+ exactly when error telemetry is most valuable.
16
+ """
17
+ from flask import g, request
18
+
19
+ @app.before_request
20
+ def _REQLY_before_request():
21
+ setattr(g, _START_TIME_ATTR, time.perf_counter())
22
+
23
+ @app.teardown_request
24
+ def _REQLY_teardown_request(exc):
25
+ start = getattr(g, _START_TIME_ATTR, None)
26
+ if start is None:
27
+ return
28
+ duration_ms = (time.perf_counter() - start) * 1000
29
+
30
+ route_template = None
31
+ if request.url_rule is not None:
32
+ route_template = request.url_rule.rule
33
+ normalized = normalize_route(request.path, route_template)
34
+
35
+ if exc is not None:
36
+ status_code = 500
37
+ error = True
38
+ error_type = type(exc).__name__
39
+ else:
40
+ response = getattr(g, "_REQLY_response_status", None)
41
+ status_code = response if response is not None else 200
42
+ error = status_code >= 500
43
+ error_type = None
44
+
45
+ client.record_request(
46
+ method=request.method,
47
+ route=normalized,
48
+ status_code=status_code,
49
+ duration_ms=duration_ms,
50
+ error=error,
51
+ error_type=error_type,
52
+ )
53
+
54
+ @app.after_request
55
+ def _REQLY_after_request(response):
56
+ g._REQLY_response_status = response.status_code
57
+ return response
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: reqly
3
+ Version: 0.1.0
4
+ Summary: Auto-instrumentation SDK for FastAPI and Flask: zero-code latency, error rates, and status codes shipped to a Reqly collector.
5
+ Author: reqly
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/tanisheesh/reqly
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/CONTRIBUTING.md
11
+ Keywords: observability,apm,monitoring,fastapi,flask,telemetry,metrics,latency,tracing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Topic :: System :: Monitoring
21
+ Classifier: Framework :: FastAPI
22
+ Classifier: Framework :: Flask
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: httpx<1.0,>=0.27
26
+ Provides-Extra: fastapi
27
+ Requires-Dist: fastapi>=0.100; extra == "fastapi"
28
+ Provides-Extra: flask
29
+ Requires-Dist: flask>=2.3; extra == "flask"
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8.0; extra == "dev"
32
+ Requires-Dist: fastapi>=0.100; extra == "dev"
33
+ Requires-Dist: flask>=2.3; extra == "dev"
34
+ Requires-Dist: httpx>=0.27; extra == "dev"
35
+
36
+ # Reqly
37
+
38
+ Auto-instrumentation SDK for FastAPI and Flask. One line captures latency, status codes, and error rates for every request and ships them to an [Reqly collector](../collector).
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install reqly
44
+ ```
45
+
46
+ (For local development against this monorepo: `pip install -e ./sdk[dev]`.)
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ from fastapi import FastAPI
52
+ import reqly
53
+
54
+ app = FastAPI()
55
+ reqly.instrument(app, service_name="checkout-api")
56
+ ```
57
+
58
+ ```python
59
+ from flask import Flask
60
+ import reqly
61
+
62
+ app = Flask(__name__)
63
+ reqly.instrument(app, service_name="checkout-api")
64
+ ```
65
+
66
+ `instrument()` auto-detects FastAPI vs Flask — no other code changes needed.
67
+
68
+ ## Configuration
69
+
70
+ Every option can be passed as a kwarg to `instrument()` or set via environment variable. Resolution order: kwarg > env var > default.
71
+
72
+ | kwarg | env var | default |
73
+ |---|---|---|
74
+ | `service_name` | `REQLY_SERVICE_NAME` | `sys.argv[0]` basename |
75
+ | `collector_url` | `REQLY_COLLECTOR_URL` | `http://localhost:8000` |
76
+ | `api_key` | `REQLY_API_KEY` | `None` |
77
+ | `sample_rate` | `REQLY_SAMPLE_RATE` | `1.0` |
78
+ | `flush_interval_seconds` | `REQLY_FLUSH_INTERVAL_SECONDS` | `5.0` |
79
+ | `max_batch_size` | `REQLY_MAX_BATCH_SIZE` | `200` |
80
+ | `max_queue_size` | `REQLY_MAX_QUEUE_SIZE` | `2000` |
81
+ | `ignore_routes` | `REQLY_IGNORE_ROUTES` (comma-separated) | `/health,/metrics` |
82
+ | `capture_request_body` | `REQLY_CAPTURE_REQUEST_BODY` | `False` |
83
+
84
+ ## Design guarantees
85
+
86
+ - **Fails open.** Any internal SDK error is caught and logged once; instrumentation then disables itself rather than risk repeatedly raising into your app. A slow or unreachable collector never slows down your app — all shipping happens off a background thread with strict per-phase HTTP timeouts.
87
+ - **Bounded cardinality.** Captured routes are the framework's matched route *template* (`/users/{id}`), never the raw path (`/users/123`). Unmatched paths (404s, scanners) collapse into a single `__unmatched__` bucket instead of blowing up cardinality.
88
+ - **Bounded memory.** Events are held in a fixed-size in-memory queue; under sustained backpressure, the oldest events are dropped (and counted) rather than growing unbounded or blocking request threads.
89
+
90
+ ## Architecture
91
+
92
+ See [`../ARCHITECTURE.md`](../ARCHITECTURE.md#2-sdk-design) for the full design rationale (why pure ASGI middleware over `BaseHTTPMiddleware`, why a background thread over `asyncio.create_task`, why `httpx` over `requests`, etc).
93
+
94
+ ## Running tests
95
+
96
+ ```bash
97
+ pip install -e .[dev]
98
+ pytest
99
+ ```
@@ -0,0 +1,15 @@
1
+ reqly/__init__.py,sha256=IRpMogn8kkSO_Um-jzG93SCR1xRiI1R-iPMkMYeA9_Q,2893
2
+ reqly/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ reqly/core/buffer.py,sha256=Ufsw_8KT_K6gvrzYWkp49U0kHdbEOWGTkqMiefq_CdI,2998
4
+ reqly/core/capture.py,sha256=6KYr_pZlcARD2EVnTQJAAWnqLk1mNyAy6iMDGZ3VcTY,1761
5
+ reqly/core/client.py,sha256=GYNZgyRgCDz1VKcABPZ3xeeTutxEhhGk6CZAHhJwTwo,3026
6
+ reqly/core/config.py,sha256=78yrdynprkchC2h4qH5XdrKjY93CY3BaLPtfFZLQhwI,3476
7
+ reqly/core/sampling.py,sha256=3-QyPzE3KWqYq5Gk4I6DcsZg3bz9ONZqoo7_6A8LVxA,1245
8
+ reqly/core/shipper.py,sha256=h3f8F1Fny9lHVd8axJ5HttgQpGvd5Xf0kIl6Yy4fHCE,2727
9
+ reqly/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ reqly/integrations/fastapi.py,sha256=M_4IXJSLJGxEIkapBnnAGYCx0s5Mren6VppJfNeUKcs,2218
11
+ reqly/integrations/flask.py,sha256=DDNpJoOBX50j4_Zp1607BslYj8zcSD7narCRPUWp7Ek,1795
12
+ reqly-0.1.0.dist-info/METADATA,sha256=zrRF3Y6_ZsPPi3P-oPvnxUdlAzKJOLTe5hvaeMyQI_E,4119
13
+ reqly-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
+ reqly-0.1.0.dist-info/top_level.txt,sha256=gAwxcUgGJPEqEOijYFYSnP5Gdaxc0KCKC8VyvsuIRWs,6
15
+ reqly-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ reqly