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 +90 -0
- reqly/core/__init__.py +0 -0
- reqly/core/buffer.py +95 -0
- reqly/core/capture.py +66 -0
- reqly/core/client.py +97 -0
- reqly/core/config.py +113 -0
- reqly/core/sampling.py +39 -0
- reqly/core/shipper.py +95 -0
- reqly/integrations/__init__.py +0 -0
- reqly/integrations/fastapi.py +64 -0
- reqly/integrations/flask.py +57 -0
- reqly-0.1.0.dist-info/METADATA +99 -0
- reqly-0.1.0.dist-info/RECORD +15 -0
- reqly-0.1.0.dist-info/WHEEL +5 -0
- reqly-0.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
reqly
|