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.
@@ -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
+ )