road24-artifacthub 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.
- road24_artifacthub-0.1.0.dist-info/METADATA +122 -0
- road24_artifacthub-0.1.0.dist-info/RECORD +18 -0
- road24_artifacthub-0.1.0.dist-info/WHEEL +4 -0
- road24_sdk/__init__.py +65 -0
- road24_sdk/_formatter.py +213 -0
- road24_sdk/_sanitizer.py +61 -0
- road24_sdk/_schemas.py +117 -0
- road24_sdk/_types.py +54 -0
- road24_sdk/integrations/__init__.py +13 -0
- road24_sdk/integrations/_base.py +19 -0
- road24_sdk/integrations/fastapi.py +258 -0
- road24_sdk/integrations/httpx.py +120 -0
- road24_sdk/integrations/redis.py +233 -0
- road24_sdk/integrations/sqlalchemy.py +175 -0
- road24_sdk/metrics/__init__.py +9 -0
- road24_sdk/metrics/db.py +29 -0
- road24_sdk/metrics/http.py +33 -0
- road24_sdk/metrics/redis.py +29 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from road24_sdk._formatter import clear_trace_context, get_trace_id, set_trace_context
|
|
10
|
+
from road24_sdk._sanitizer import sanitize_body
|
|
11
|
+
from road24_sdk._schemas import HttpAttributes
|
|
12
|
+
from road24_sdk._types import HttpDirection, LogType
|
|
13
|
+
from road24_sdk.integrations._base import Integration
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_request_exception: ContextVar[Exception | None] = ContextVar("request_exception", default=None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HttpInputLogger:
|
|
24
|
+
def log(
|
|
25
|
+
self,
|
|
26
|
+
scope: "Scope",
|
|
27
|
+
status_code: int,
|
|
28
|
+
duration_seconds: float,
|
|
29
|
+
request_body: bytes = b"",
|
|
30
|
+
response_body: bytes = b"",
|
|
31
|
+
record_metrics: bool = True,
|
|
32
|
+
exc: Exception | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
method = scope.get("method", "")
|
|
35
|
+
path = scope.get("path", "")
|
|
36
|
+
scheme = scope.get("scheme", "http")
|
|
37
|
+
headers = dict(scope.get("headers", []))
|
|
38
|
+
host = headers.get(b"host", b"").decode() or ""
|
|
39
|
+
url = f"{scheme}://{host}{path}" if host else path
|
|
40
|
+
|
|
41
|
+
if record_metrics:
|
|
42
|
+
from road24_sdk.metrics.http import record_http_request
|
|
43
|
+
|
|
44
|
+
record_http_request(
|
|
45
|
+
method=method,
|
|
46
|
+
url=url,
|
|
47
|
+
status_code=status_code,
|
|
48
|
+
duration_seconds=duration_seconds,
|
|
49
|
+
direction=HttpDirection.INPUT,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
http_attrs = HttpAttributes(
|
|
53
|
+
direction=HttpDirection.INPUT,
|
|
54
|
+
method=method,
|
|
55
|
+
url=url,
|
|
56
|
+
target=path,
|
|
57
|
+
status_code=status_code,
|
|
58
|
+
duration_seconds=duration_seconds,
|
|
59
|
+
request_body=self._decode_body(request_body),
|
|
60
|
+
response_body=self._decode_body(response_body),
|
|
61
|
+
error_class=type(exc).__name__ if exc else "",
|
|
62
|
+
error_message=str(exc) if exc else "",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
log_level = self._get_log_level(status_code)
|
|
66
|
+
getattr(logger, log_level)(
|
|
67
|
+
LogType.HTTP_REQUEST, exc_info=exc if exc else None, extra=http_attrs.as_dict()
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def generate_trace_id(self, scope: "Scope") -> str:
|
|
71
|
+
headers = dict(scope.get("headers", []))
|
|
72
|
+
return headers.get(b"x-trace-id", b"").decode() or secrets.token_hex(16)
|
|
73
|
+
|
|
74
|
+
def _decode_body(self, body: bytes) -> str:
|
|
75
|
+
if not body:
|
|
76
|
+
return ""
|
|
77
|
+
try:
|
|
78
|
+
decoded = body.decode("utf-8")
|
|
79
|
+
return sanitize_body(decoded)
|
|
80
|
+
except UnicodeDecodeError:
|
|
81
|
+
return "<binary data>"
|
|
82
|
+
|
|
83
|
+
def _get_log_level(self, status_code: int) -> str:
|
|
84
|
+
if status_code < HTTPStatus.BAD_REQUEST:
|
|
85
|
+
return "info"
|
|
86
|
+
if status_code < HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
87
|
+
return "warning"
|
|
88
|
+
return "error"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FastApiLoggingIntegration(Integration):
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
enable_logging: bool = True,
|
|
96
|
+
enable_metrics: bool = True,
|
|
97
|
+
enable_exception_handlers: bool = True,
|
|
98
|
+
) -> None:
|
|
99
|
+
self._enable_logging = enable_logging
|
|
100
|
+
self._enable_metrics = enable_metrics
|
|
101
|
+
self._enable_exception_handlers = enable_exception_handlers
|
|
102
|
+
|
|
103
|
+
def setup_once(self) -> None:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def setup(self, app: "ASGIApp") -> None:
|
|
107
|
+
if self._enable_exception_handlers and self._is_fastapi_app(app):
|
|
108
|
+
self._register_exception_handlers(app)
|
|
109
|
+
|
|
110
|
+
app.add_middleware(
|
|
111
|
+
_LogMiddleware,
|
|
112
|
+
enable_logging=self._enable_logging,
|
|
113
|
+
enable_metrics=self._enable_metrics,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _is_fastapi_app(app: Any) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
from starlette.applications import Starlette
|
|
120
|
+
except ImportError:
|
|
121
|
+
return False
|
|
122
|
+
return isinstance(app, Starlette)
|
|
123
|
+
|
|
124
|
+
def _register_exception_handlers(self, app: Any) -> None:
|
|
125
|
+
from fastapi import HTTPException
|
|
126
|
+
from fastapi.exceptions import RequestValidationError
|
|
127
|
+
|
|
128
|
+
app.add_exception_handler(HTTPException, _http_exception_handler)
|
|
129
|
+
app.add_exception_handler(RequestValidationError, _validation_exception_handler)
|
|
130
|
+
app.add_exception_handler(Exception, _general_exception_handler)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _get_trace_id_for_response() -> str | None:
|
|
134
|
+
trace_id = get_trace_id()
|
|
135
|
+
return trace_id if trace_id != "0" * 32 else None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _http_exception_handler(request: Any, exc: Any) -> Any:
|
|
139
|
+
from fastapi.responses import JSONResponse
|
|
140
|
+
|
|
141
|
+
if exc.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
142
|
+
_request_exception.set(exc)
|
|
143
|
+
|
|
144
|
+
return JSONResponse(
|
|
145
|
+
status_code=exc.status_code,
|
|
146
|
+
content={"detail": exc.detail, "trace_id": _get_trace_id_for_response()},
|
|
147
|
+
headers=getattr(exc, "headers", None),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _validation_exception_handler(request: Any, exc: Any) -> Any:
|
|
152
|
+
from fastapi.responses import JSONResponse
|
|
153
|
+
|
|
154
|
+
_request_exception.set(exc)
|
|
155
|
+
|
|
156
|
+
return JSONResponse(
|
|
157
|
+
status_code=HTTPStatus.UNPROCESSABLE_ENTITY.value,
|
|
158
|
+
content={"detail": exc.errors(), "trace_id": _get_trace_id_for_response()},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _general_exception_handler(request: Any, exc: Exception) -> Any:
|
|
163
|
+
from fastapi.responses import JSONResponse
|
|
164
|
+
|
|
165
|
+
_request_exception.set(exc)
|
|
166
|
+
|
|
167
|
+
return JSONResponse(
|
|
168
|
+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
|
|
169
|
+
content={
|
|
170
|
+
"detail": "Internal server error",
|
|
171
|
+
"trace_id": _get_trace_id_for_response(),
|
|
172
|
+
"error_type": type(exc).__name__,
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _LogMiddleware:
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
app: "ASGIApp",
|
|
181
|
+
*,
|
|
182
|
+
enable_logging: bool = True,
|
|
183
|
+
enable_metrics: bool = True,
|
|
184
|
+
) -> None:
|
|
185
|
+
self.app = app
|
|
186
|
+
self._http_logger = HttpInputLogger()
|
|
187
|
+
self._enable_logging = enable_logging
|
|
188
|
+
self._enable_metrics = enable_metrics
|
|
189
|
+
|
|
190
|
+
async def __call__(
|
|
191
|
+
self,
|
|
192
|
+
scope: "Scope",
|
|
193
|
+
receive: "Receive",
|
|
194
|
+
send: "Send",
|
|
195
|
+
) -> None:
|
|
196
|
+
if scope["type"] != "http" or scope.get("path") == "/metrics":
|
|
197
|
+
await self.app(scope, receive, send)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
trace_id = self._http_logger.generate_trace_id(scope)
|
|
201
|
+
trace_token = set_trace_context(trace_id)
|
|
202
|
+
|
|
203
|
+
request_body = b""
|
|
204
|
+
response_body = b""
|
|
205
|
+
status_code = 500
|
|
206
|
+
start_time = time.perf_counter()
|
|
207
|
+
|
|
208
|
+
async def receive_wrapper() -> "Message":
|
|
209
|
+
nonlocal request_body
|
|
210
|
+
message = await receive()
|
|
211
|
+
if message["type"] == "http.request":
|
|
212
|
+
request_body += message.get("body", b"")
|
|
213
|
+
return message
|
|
214
|
+
|
|
215
|
+
async def send_wrapper(message: "Message") -> None:
|
|
216
|
+
nonlocal response_body, status_code
|
|
217
|
+
if message["type"] == "http.response.start":
|
|
218
|
+
status_code = message["status"]
|
|
219
|
+
headers = [
|
|
220
|
+
*message.get("headers", []),
|
|
221
|
+
(b"x-trace-id", trace_id.encode()),
|
|
222
|
+
]
|
|
223
|
+
message = {**message, "headers": headers}
|
|
224
|
+
elif message["type"] == "http.response.body":
|
|
225
|
+
response_body += message.get("body", b"")
|
|
226
|
+
await send(message)
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
await self.app(scope, receive_wrapper, send_wrapper)
|
|
230
|
+
except Exception as e:
|
|
231
|
+
_request_exception.set(e)
|
|
232
|
+
status_code = HTTPStatus.INTERNAL_SERVER_ERROR
|
|
233
|
+
error_body = json.dumps({
|
|
234
|
+
"detail": "Internal server error",
|
|
235
|
+
"trace_id": trace_id,
|
|
236
|
+
"error_type": type(e).__name__,
|
|
237
|
+
}).encode()
|
|
238
|
+
await send({"type": "http.response.start", "status": status_code, "headers": [
|
|
239
|
+
(b"content-type", b"application/json"),
|
|
240
|
+
(b"x-trace-id", trace_id.encode()),
|
|
241
|
+
]})
|
|
242
|
+
await send({"type": "http.response.body", "body": error_body})
|
|
243
|
+
response_body = error_body
|
|
244
|
+
finally:
|
|
245
|
+
exc = _request_exception.get()
|
|
246
|
+
_request_exception.set(None)
|
|
247
|
+
duration_seconds = time.perf_counter() - start_time
|
|
248
|
+
if self._enable_logging:
|
|
249
|
+
self._http_logger.log(
|
|
250
|
+
scope=scope,
|
|
251
|
+
status_code=status_code,
|
|
252
|
+
duration_seconds=duration_seconds,
|
|
253
|
+
request_body=request_body,
|
|
254
|
+
response_body=response_body,
|
|
255
|
+
record_metrics=self._enable_metrics,
|
|
256
|
+
exc=exc,
|
|
257
|
+
)
|
|
258
|
+
clear_trace_context(trace_token)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from road24_sdk._sanitizer import sanitize_body
|
|
8
|
+
from road24_sdk._schemas import HttpAttributes
|
|
9
|
+
from road24_sdk._types import HttpDirection, LogType
|
|
10
|
+
from road24_sdk.integrations._base import Integration
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from httpx import AsyncClient, Request, Response
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpOutputLogger:
|
|
19
|
+
async def log_request_hook(self, request: "Request") -> None:
|
|
20
|
+
request.extensions["start_time"] = time.perf_counter()
|
|
21
|
+
|
|
22
|
+
async def log_response_hook(self, response: "Response") -> None:
|
|
23
|
+
await response.aread()
|
|
24
|
+
start_time = response.request.extensions.get("start_time")
|
|
25
|
+
duration_seconds = time.perf_counter() - start_time if start_time else 0.0
|
|
26
|
+
self.log(response.request, response, duration_seconds)
|
|
27
|
+
|
|
28
|
+
def log(
|
|
29
|
+
self,
|
|
30
|
+
request: "Request",
|
|
31
|
+
response: "Response",
|
|
32
|
+
duration_seconds: float,
|
|
33
|
+
record_metrics: bool = True,
|
|
34
|
+
) -> None:
|
|
35
|
+
parsed = urlparse(str(request.url))
|
|
36
|
+
url = str(request.url)
|
|
37
|
+
|
|
38
|
+
if record_metrics:
|
|
39
|
+
from road24_sdk.metrics.http import record_http_request
|
|
40
|
+
|
|
41
|
+
record_http_request(
|
|
42
|
+
method=request.method,
|
|
43
|
+
url=url,
|
|
44
|
+
status_code=response.status_code,
|
|
45
|
+
duration_seconds=duration_seconds,
|
|
46
|
+
direction=HttpDirection.OUTPUT,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
request_body = request.content.decode("utf-8") if request.content else ""
|
|
50
|
+
response_body = response.text if response.text else ""
|
|
51
|
+
|
|
52
|
+
http_attrs = HttpAttributes(
|
|
53
|
+
direction=HttpDirection.OUTPUT,
|
|
54
|
+
method=request.method,
|
|
55
|
+
url=url,
|
|
56
|
+
target=parsed.path,
|
|
57
|
+
status_code=response.status_code,
|
|
58
|
+
duration_seconds=duration_seconds,
|
|
59
|
+
request_body=sanitize_body(request_body),
|
|
60
|
+
response_body=sanitize_body(response_body),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
log_level = self._get_log_level(response.status_code)
|
|
64
|
+
getattr(logger, log_level)(LogType.HTTP_REQUEST, extra=http_attrs.as_dict())
|
|
65
|
+
|
|
66
|
+
def _get_log_level(self, status_code: int) -> str:
|
|
67
|
+
if status_code < HTTPStatus.BAD_REQUEST:
|
|
68
|
+
return "info"
|
|
69
|
+
if status_code < HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
70
|
+
return "warning"
|
|
71
|
+
return "error"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class HttpxLoggingIntegration(Integration):
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
enable_logging: bool = True,
|
|
79
|
+
enable_metrics: bool = True,
|
|
80
|
+
) -> None:
|
|
81
|
+
self._enable_logging = enable_logging
|
|
82
|
+
self._enable_metrics = enable_metrics
|
|
83
|
+
self._output_logger = HttpOutputLogger()
|
|
84
|
+
|
|
85
|
+
def setup_once(self) -> None:
|
|
86
|
+
if self._mark_setup_once():
|
|
87
|
+
return
|
|
88
|
+
from httpx import AsyncClient
|
|
89
|
+
|
|
90
|
+
original_init = AsyncClient.__init__
|
|
91
|
+
output_logger = self._output_logger
|
|
92
|
+
enable_logging = self._enable_logging
|
|
93
|
+
|
|
94
|
+
def _patched_init(self_client: "AsyncClient", *args: Any, **kwargs: Any) -> None:
|
|
95
|
+
original_init(self_client, *args, **kwargs)
|
|
96
|
+
if enable_logging:
|
|
97
|
+
hooks = self_client.event_hooks
|
|
98
|
+
hooks["request"].append(output_logger.log_request_hook)
|
|
99
|
+
hooks["response"].append(output_logger.log_response_hook)
|
|
100
|
+
|
|
101
|
+
AsyncClient.__init__ = _patched_init # type: ignore[method-assign]
|
|
102
|
+
|
|
103
|
+
def setup(self, client: "AsyncClient") -> None:
|
|
104
|
+
if self._enable_logging:
|
|
105
|
+
hooks = client.event_hooks
|
|
106
|
+
hooks["request"].append(self._output_logger.log_request_hook)
|
|
107
|
+
hooks["response"].append(self._output_logger.log_response_hook)
|
|
108
|
+
|
|
109
|
+
def create_client(self, **kwargs: Any) -> "AsyncClient":
|
|
110
|
+
from httpx import AsyncClient
|
|
111
|
+
|
|
112
|
+
event_hooks: dict[str, list[Any]] = kwargs.pop(
|
|
113
|
+
"event_hooks", {"request": [], "response": []}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if self._enable_logging:
|
|
117
|
+
event_hooks["request"].append(self._output_logger.log_request_hook)
|
|
118
|
+
event_hooks["response"].append(self._output_logger.log_response_hook)
|
|
119
|
+
|
|
120
|
+
return AsyncClient(event_hooks=event_hooks, **kwargs)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from road24_sdk._schemas import RedisAttributes
|
|
6
|
+
from road24_sdk._types import LogType, RedisOperation
|
|
7
|
+
from road24_sdk.integrations._base import Integration
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from redis.asyncio import Redis
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
OPERATION_MAP: dict[str, RedisOperation] = {
|
|
15
|
+
"get": RedisOperation.GET,
|
|
16
|
+
"set": RedisOperation.SET,
|
|
17
|
+
"delete": RedisOperation.DELETE,
|
|
18
|
+
"del": RedisOperation.DELETE,
|
|
19
|
+
"expire": RedisOperation.EXPIRE,
|
|
20
|
+
"hget": RedisOperation.HGET,
|
|
21
|
+
"hset": RedisOperation.HSET,
|
|
22
|
+
"lpush": RedisOperation.LPUSH,
|
|
23
|
+
"rpush": RedisOperation.RPUSH,
|
|
24
|
+
"lpop": RedisOperation.LPOP,
|
|
25
|
+
"rpop": RedisOperation.RPOP,
|
|
26
|
+
"publish": RedisOperation.PUBLISH,
|
|
27
|
+
"subscribe": RedisOperation.SUBSCRIBE,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RedisLogger:
|
|
32
|
+
def __init__(self, *, record_metrics: bool = True) -> None:
|
|
33
|
+
self._record_metrics = record_metrics
|
|
34
|
+
|
|
35
|
+
def log(
|
|
36
|
+
self,
|
|
37
|
+
operation: RedisOperation,
|
|
38
|
+
key: str,
|
|
39
|
+
duration_seconds: float,
|
|
40
|
+
args: str = "",
|
|
41
|
+
) -> None:
|
|
42
|
+
if self._record_metrics:
|
|
43
|
+
from road24_sdk.metrics.redis import record_redis_command
|
|
44
|
+
|
|
45
|
+
record_redis_command(
|
|
46
|
+
operation=operation,
|
|
47
|
+
key=key,
|
|
48
|
+
duration_seconds=duration_seconds,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
redis_attrs = RedisAttributes(
|
|
52
|
+
operation=operation,
|
|
53
|
+
key=key,
|
|
54
|
+
duration_seconds=duration_seconds,
|
|
55
|
+
command_args=args,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
logger.info(LogType.REDIS_COMMAND, extra=redis_attrs.as_dict())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LoggedRedis:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
client: "Redis",
|
|
65
|
+
*,
|
|
66
|
+
record_metrics: bool = True,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._client = client
|
|
69
|
+
self._logger = RedisLogger(record_metrics=record_metrics)
|
|
70
|
+
|
|
71
|
+
async def get(self, key: str) -> Any:
|
|
72
|
+
return await self._execute("get", key)
|
|
73
|
+
|
|
74
|
+
async def set(
|
|
75
|
+
self,
|
|
76
|
+
key: str,
|
|
77
|
+
value: Any,
|
|
78
|
+
ex: int | None = None,
|
|
79
|
+
px: int | None = None,
|
|
80
|
+
nx: bool = False,
|
|
81
|
+
xx: bool = False,
|
|
82
|
+
) -> Any:
|
|
83
|
+
kwargs: dict[str, Any] = {}
|
|
84
|
+
if ex is not None:
|
|
85
|
+
kwargs["ex"] = ex
|
|
86
|
+
if px is not None:
|
|
87
|
+
kwargs["px"] = px
|
|
88
|
+
if nx:
|
|
89
|
+
kwargs["nx"] = nx
|
|
90
|
+
if xx:
|
|
91
|
+
kwargs["xx"] = xx
|
|
92
|
+
|
|
93
|
+
args_str = f"value={value}"
|
|
94
|
+
if kwargs:
|
|
95
|
+
args_str += ", " + ", ".join(f"{k}={v}" for k, v in kwargs.items())
|
|
96
|
+
|
|
97
|
+
return await self._execute("set", key, value, args_str=args_str, **kwargs)
|
|
98
|
+
|
|
99
|
+
async def delete(self, *keys: str) -> int:
|
|
100
|
+
key = keys[0] if keys else "unknown"
|
|
101
|
+
return await self._execute("delete", key, *keys)
|
|
102
|
+
|
|
103
|
+
async def expire(self, key: str, seconds: int) -> bool:
|
|
104
|
+
return await self._execute("expire", key, seconds, args_str=f"seconds={seconds}")
|
|
105
|
+
|
|
106
|
+
async def hget(self, name: str, key: str) -> Any:
|
|
107
|
+
return await self._execute("hget", name, key, args_str=f"field={key}")
|
|
108
|
+
|
|
109
|
+
async def hset(
|
|
110
|
+
self,
|
|
111
|
+
name: str,
|
|
112
|
+
key: str | None = None,
|
|
113
|
+
value: Any = None,
|
|
114
|
+
mapping: dict[str, Any] | None = None,
|
|
115
|
+
) -> int:
|
|
116
|
+
args_str = f"field={key}" if key else f"mapping={len(mapping or {})} fields"
|
|
117
|
+
return await self._execute(
|
|
118
|
+
"hset",
|
|
119
|
+
name,
|
|
120
|
+
key,
|
|
121
|
+
value,
|
|
122
|
+
mapping=mapping,
|
|
123
|
+
args_str=args_str,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def lpush(self, key: str, *values: Any) -> int:
|
|
127
|
+
return await self._execute("lpush", key, *values, args_str=f"count={len(values)}")
|
|
128
|
+
|
|
129
|
+
async def rpush(self, key: str, *values: Any) -> int:
|
|
130
|
+
return await self._execute("rpush", key, *values, args_str=f"count={len(values)}")
|
|
131
|
+
|
|
132
|
+
async def lpop(self, key: str, count: int | None = None) -> Any:
|
|
133
|
+
return await self._execute("lpop", key, count=count)
|
|
134
|
+
|
|
135
|
+
async def rpop(self, key: str, count: int | None = None) -> Any:
|
|
136
|
+
return await self._execute("rpop", key, count=count)
|
|
137
|
+
|
|
138
|
+
async def ping(self) -> bool:
|
|
139
|
+
return await self._client.ping()
|
|
140
|
+
|
|
141
|
+
async def aclose(self) -> None:
|
|
142
|
+
await self._client.aclose()
|
|
143
|
+
|
|
144
|
+
async def _execute(
|
|
145
|
+
self,
|
|
146
|
+
command: str,
|
|
147
|
+
key: str,
|
|
148
|
+
*args: Any,
|
|
149
|
+
args_str: str = "",
|
|
150
|
+
**kwargs: Any,
|
|
151
|
+
) -> Any:
|
|
152
|
+
operation = OPERATION_MAP.get(command, RedisOperation.OTHER)
|
|
153
|
+
start_time = time.perf_counter()
|
|
154
|
+
try:
|
|
155
|
+
method = getattr(self._client, command)
|
|
156
|
+
return await method(key, *args, **kwargs)
|
|
157
|
+
finally:
|
|
158
|
+
duration = time.perf_counter() - start_time
|
|
159
|
+
self._logger.log(
|
|
160
|
+
operation=operation,
|
|
161
|
+
key=key,
|
|
162
|
+
duration_seconds=duration,
|
|
163
|
+
args=args_str,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class RedisLoggingIntegration(Integration):
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
enable_logging: bool = True,
|
|
172
|
+
enable_metrics: bool = True,
|
|
173
|
+
) -> None:
|
|
174
|
+
self._enable_logging = enable_logging
|
|
175
|
+
self._enable_metrics = enable_metrics
|
|
176
|
+
|
|
177
|
+
def setup_once(self) -> None:
|
|
178
|
+
if self._mark_setup_once():
|
|
179
|
+
return
|
|
180
|
+
from redis.asyncio import Redis
|
|
181
|
+
|
|
182
|
+
original_execute = Redis.execute_command
|
|
183
|
+
redis_logger = RedisLogger(record_metrics=self._enable_metrics)
|
|
184
|
+
enable_logging = self._enable_logging
|
|
185
|
+
|
|
186
|
+
async def _patched_execute(
|
|
187
|
+
self_client: "Redis", *args: Any, **kwargs: Any,
|
|
188
|
+
) -> Any:
|
|
189
|
+
command = str(args[0]).lower() if args else "unknown"
|
|
190
|
+
key = str(args[1]) if len(args) > 1 else ""
|
|
191
|
+
operation = OPERATION_MAP.get(command, RedisOperation.OTHER)
|
|
192
|
+
start_time = time.perf_counter()
|
|
193
|
+
try:
|
|
194
|
+
return await original_execute(self_client, *args, **kwargs)
|
|
195
|
+
finally:
|
|
196
|
+
duration = time.perf_counter() - start_time
|
|
197
|
+
if enable_logging:
|
|
198
|
+
redis_logger.log(
|
|
199
|
+
operation=operation,
|
|
200
|
+
key=key,
|
|
201
|
+
duration_seconds=duration,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
Redis.execute_command = _patched_execute # type: ignore[method-assign]
|
|
205
|
+
|
|
206
|
+
def setup(self, client: "Redis") -> None:
|
|
207
|
+
original_execute = client.execute_command
|
|
208
|
+
redis_logger = RedisLogger(record_metrics=self._enable_metrics)
|
|
209
|
+
enable_logging = self._enable_logging
|
|
210
|
+
|
|
211
|
+
async def _patched_execute(*args: Any, **kwargs: Any) -> Any:
|
|
212
|
+
command = str(args[0]).lower() if args else "unknown"
|
|
213
|
+
key = str(args[1]) if len(args) > 1 else ""
|
|
214
|
+
operation = OPERATION_MAP.get(command, RedisOperation.OTHER)
|
|
215
|
+
start_time = time.perf_counter()
|
|
216
|
+
try:
|
|
217
|
+
return await original_execute(*args, **kwargs)
|
|
218
|
+
finally:
|
|
219
|
+
duration = time.perf_counter() - start_time
|
|
220
|
+
if enable_logging:
|
|
221
|
+
redis_logger.log(
|
|
222
|
+
operation=operation,
|
|
223
|
+
key=key,
|
|
224
|
+
duration_seconds=duration,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
client.execute_command = _patched_execute # type: ignore[method-assign]
|
|
228
|
+
|
|
229
|
+
def instrument(self, client: "Redis") -> LoggedRedis:
|
|
230
|
+
return LoggedRedis(
|
|
231
|
+
client,
|
|
232
|
+
record_metrics=self._enable_metrics,
|
|
233
|
+
)
|