elven-logs-interceptor-python 0.1.2__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.
Files changed (56) hide show
  1. elven_logs_interceptor_python-0.1.2.dist-info/METADATA +262 -0
  2. elven_logs_interceptor_python-0.1.2.dist-info/RECORD +56 -0
  3. elven_logs_interceptor_python-0.1.2.dist-info/WHEEL +4 -0
  4. logs_interceptor/__init__.py +333 -0
  5. logs_interceptor/application/__init__.py +27 -0
  6. logs_interceptor/application/config_service.py +232 -0
  7. logs_interceptor/application/log_service.py +383 -0
  8. logs_interceptor/config.py +190 -0
  9. logs_interceptor/domain/__init__.py +25 -0
  10. logs_interceptor/domain/entities.py +41 -0
  11. logs_interceptor/domain/interfaces.py +149 -0
  12. logs_interceptor/domain/value_objects.py +40 -0
  13. logs_interceptor/infrastructure/__init__.py +48 -0
  14. logs_interceptor/infrastructure/buffer/__init__.py +3 -0
  15. logs_interceptor/infrastructure/buffer/memory_buffer.py +187 -0
  16. logs_interceptor/infrastructure/circuit_breaker/__init__.py +3 -0
  17. logs_interceptor/infrastructure/circuit_breaker/circuit_breaker.py +110 -0
  18. logs_interceptor/infrastructure/compression/__init__.py +14 -0
  19. logs_interceptor/infrastructure/compression/base.py +20 -0
  20. logs_interceptor/infrastructure/compression/brotli_compressor.py +27 -0
  21. logs_interceptor/infrastructure/compression/factory.py +18 -0
  22. logs_interceptor/infrastructure/compression/gzip_compressor.py +20 -0
  23. logs_interceptor/infrastructure/compression/noop_compressor.py +14 -0
  24. logs_interceptor/infrastructure/context/__init__.py +3 -0
  25. logs_interceptor/infrastructure/context/context_provider.py +44 -0
  26. logs_interceptor/infrastructure/dlq/__init__.py +4 -0
  27. logs_interceptor/infrastructure/dlq/file_dlq.py +170 -0
  28. logs_interceptor/infrastructure/dlq/memory_dlq.py +59 -0
  29. logs_interceptor/infrastructure/filter/__init__.py +3 -0
  30. logs_interceptor/infrastructure/filter/log_filter.py +55 -0
  31. logs_interceptor/infrastructure/interceptors/__init__.py +3 -0
  32. logs_interceptor/infrastructure/interceptors/runtime_interceptor.py +139 -0
  33. logs_interceptor/infrastructure/memory/__init__.py +3 -0
  34. logs_interceptor/infrastructure/memory/memory_tracker.py +95 -0
  35. logs_interceptor/infrastructure/metrics/__init__.py +3 -0
  36. logs_interceptor/infrastructure/metrics/metrics_collector.py +104 -0
  37. logs_interceptor/infrastructure/transport/__init__.py +12 -0
  38. logs_interceptor/infrastructure/transport/loki_json_transport.py +226 -0
  39. logs_interceptor/infrastructure/transport/loki_protobuf_transport.py +209 -0
  40. logs_interceptor/infrastructure/transport/resilient_transport.py +161 -0
  41. logs_interceptor/infrastructure/transport/transport_factory.py +39 -0
  42. logs_interceptor/infrastructure/workers/__init__.py +3 -0
  43. logs_interceptor/infrastructure/workers/worker_pool.py +57 -0
  44. logs_interceptor/integrations/__init__.py +17 -0
  45. logs_interceptor/integrations/celery.py +53 -0
  46. logs_interceptor/integrations/django.py +44 -0
  47. logs_interceptor/integrations/fastapi.py +53 -0
  48. logs_interceptor/integrations/flask.py +50 -0
  49. logs_interceptor/integrations/logging_handler.py +43 -0
  50. logs_interceptor/integrations/loguru.py +36 -0
  51. logs_interceptor/integrations/structlog.py +21 -0
  52. logs_interceptor/preload.py +61 -0
  53. logs_interceptor/presentation/__init__.py +3 -0
  54. logs_interceptor/presentation/factory.py +128 -0
  55. logs_interceptor/types.py +89 -0
  56. logs_interceptor/utils.py +508 -0
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any, cast
8
+
9
+ import httpx
10
+
11
+ from ...config import ResolvedTransportConfig
12
+ from ...domain.entities import LogEntryEntity
13
+ from ...types import TransportHealth, TransportMetrics
14
+
15
+ try:
16
+ import snappy # type: ignore[import-not-found]
17
+ except Exception: # pragma: no cover - optional dependency
18
+ snappy = None
19
+
20
+ @dataclass(slots=True)
21
+ class RetryableTransportError(Exception):
22
+ message: str
23
+ status_code: int | None = None
24
+ retryable: bool = False
25
+
26
+ def __str__(self) -> str:
27
+ return self.message
28
+
29
+
30
+ class LokiProtobufTransport:
31
+ """
32
+ Protobuf/Snappy transport with graceful fallback semantics.
33
+
34
+ The runtime payload is encoded as JSON bytes and then Snappy-compressed
35
+ when protobuf schemas are unavailable in environment.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ config: ResolvedTransportConfig,
41
+ extra_headers: dict[str, str] | None = None,
42
+ ) -> None:
43
+ # This transport requires a real protobuf payload implementation.
44
+ # Keep it explicitly opt-in to avoid silent breakage in production
45
+ # when users install `python-snappy` but don't expect experimental behavior.
46
+ experimental_enabled = (os.getenv("LOGS_ENABLE_EXPERIMENTAL_PROTOBUF") or "").strip().lower()
47
+ if experimental_enabled not in {"1", "true", "yes", "on"}:
48
+ raise RuntimeError("Set LOGS_ENABLE_EXPERIMENTAL_PROTOBUF=true to enable LokiProtobufTransport")
49
+
50
+ if snappy is None:
51
+ raise RuntimeError("python-snappy is required for compression='snappy'")
52
+
53
+ self._config = config
54
+ self._extra_headers = extra_headers or {}
55
+ self._timeout = config.timeout / 1000
56
+ self._client: httpx.Client | None = None
57
+ if config.enable_connection_pooling:
58
+ limits = httpx.Limits(max_keepalive_connections=config.max_sockets, max_connections=config.max_sockets)
59
+ self._client = httpx.Client(timeout=self._timeout, limits=limits)
60
+
61
+ self._health: TransportHealth = {
62
+ "healthy": True,
63
+ "consecutive_failures": 0,
64
+ }
65
+ self._metrics: TransportMetrics = {
66
+ "total_sends": 0,
67
+ "successful_sends": 0,
68
+ "failed_sends": 0,
69
+ "avg_latency": 0.0,
70
+ "avg_compression_time": 0.0,
71
+ "avg_compression_ratio": 0.0,
72
+ "total_bytes_sent": 0,
73
+ "total_bytes_compressed": 0,
74
+ }
75
+
76
+ def send(self, entries: list[LogEntryEntity]) -> None:
77
+ if not entries:
78
+ return
79
+
80
+ start = time.perf_counter()
81
+ self._metrics["total_sends"] = self._metrics.get("total_sends", 0) + 1
82
+
83
+ try:
84
+ payload = self._format_payload(entries)
85
+ raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
86
+ raw_size = len(raw)
87
+
88
+ compression_start = time.perf_counter()
89
+ compressed = cast(bytes, snappy.compress(raw))
90
+ compression_time = (time.perf_counter() - compression_start) * 1000
91
+ compressed_size = len(compressed)
92
+
93
+ headers = {
94
+ "Content-Type": "application/x-protobuf",
95
+ "Content-Encoding": "snappy",
96
+ "X-Scope-OrgID": self._config.tenant_id,
97
+ "User-Agent": "elven-logs-interceptor-python/0.1.2",
98
+ **self._extra_headers,
99
+ }
100
+ if self._config.auth_token:
101
+ headers["Authorization"] = f"Bearer {self._config.auth_token}"
102
+
103
+ response = self._request(headers, compressed)
104
+ if response.status_code >= 300:
105
+ raise RetryableTransportError(
106
+ message=f"Loki responded with {response.status_code}: {response.text}",
107
+ status_code=response.status_code,
108
+ retryable=response.status_code == 429 or response.status_code >= 500,
109
+ )
110
+
111
+ latency = (time.perf_counter() - start) * 1000
112
+ self._record_success(latency)
113
+ self._update_compression_metrics(compression_time, raw_size, compressed_size)
114
+ self._metrics["total_bytes_sent"] = self._metrics.get("total_bytes_sent", 0) + compressed_size
115
+ self._metrics["total_bytes_compressed"] = self._metrics.get("total_bytes_compressed", 0) + raw_size
116
+ except Exception as exc:
117
+ self._record_failure(exc)
118
+ raise
119
+
120
+ def _request(self, headers: dict[str, str], body: bytes) -> httpx.Response:
121
+ if self._client is not None:
122
+ return self._client.post(self._config.url, headers=headers, content=body)
123
+ with httpx.Client(timeout=self._timeout) as client:
124
+ return client.post(self._config.url, headers=headers, content=body)
125
+
126
+ def is_available(self) -> bool:
127
+ return bool(self._health.get("healthy", False))
128
+
129
+ def get_health(self) -> TransportHealth:
130
+ return cast(TransportHealth, dict(self._health))
131
+
132
+ def get_metrics(self) -> TransportMetrics:
133
+ return cast(TransportMetrics, dict(self._metrics))
134
+
135
+ def destroy(self) -> None:
136
+ if self._client is not None:
137
+ self._client.close()
138
+ self._client = None
139
+
140
+ def _record_success(self, duration_ms: float) -> None:
141
+ self._health = {
142
+ "healthy": True,
143
+ "consecutive_failures": 0,
144
+ "last_successful_send": time.time(),
145
+ }
146
+ successful = self._metrics.get("successful_sends", 0) + 1
147
+ self._metrics["successful_sends"] = successful
148
+ current_avg = float(self._metrics.get("avg_latency", 0.0))
149
+ self._metrics["avg_latency"] = ((current_avg * (successful - 1)) + duration_ms) / successful
150
+
151
+ def _record_failure(self, error: Exception) -> None:
152
+ failures = int(self._health.get("consecutive_failures", 0)) + 1
153
+ self._health = {
154
+ "healthy": False,
155
+ "consecutive_failures": failures,
156
+ "error_message": str(error),
157
+ }
158
+ self._metrics["failed_sends"] = self._metrics.get("failed_sends", 0) + 1
159
+
160
+ def _update_compression_metrics(self, duration_ms: float, raw_size: int, compressed_size: int) -> None:
161
+ successful = max(1, self._metrics.get("successful_sends", 1))
162
+ current_time = float(self._metrics.get("avg_compression_time", 0.0))
163
+ self._metrics["avg_compression_time"] = (
164
+ (current_time * (successful - 1)) + duration_ms
165
+ ) / successful
166
+
167
+ ratio = compressed_size / raw_size if raw_size > 0 else 1.0
168
+ current_ratio = float(self._metrics.get("avg_compression_ratio", 0.0))
169
+ self._metrics["avg_compression_ratio"] = (
170
+ (current_ratio * (successful - 1)) + ratio
171
+ ) / successful
172
+
173
+ def _format_payload(self, entries: list[LogEntryEntity]) -> dict[str, Any]:
174
+ streams: dict[str, dict[str, Any]] = {}
175
+ values: dict[str, list[dict[str, Any]]] = {}
176
+
177
+ for entry in entries:
178
+ labels = entry.labels or {}
179
+ key = json.dumps(labels, sort_keys=True)
180
+ if key not in streams:
181
+ streams[key] = labels
182
+ values[key] = []
183
+ values[key].append(
184
+ {
185
+ "timestamp": entry.timestamp,
186
+ "line": json.dumps(
187
+ {
188
+ "level": entry.level,
189
+ "message": entry.message,
190
+ "context": entry.context,
191
+ "traceId": entry.trace_id,
192
+ "spanId": entry.span_id,
193
+ "requestId": entry.request_id,
194
+ "metadata": entry.metadata,
195
+ },
196
+ separators=(",", ":"),
197
+ ),
198
+ }
199
+ )
200
+
201
+ return {
202
+ "streams": [
203
+ {
204
+ "labels": stream_labels,
205
+ "entries": values[key],
206
+ }
207
+ for key, stream_labels in streams.items()
208
+ ]
209
+ }
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import time
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import cast
8
+
9
+ from ...domain.entities import LogEntryEntity
10
+ from ...domain.interfaces import ICircuitBreaker, IDeadLetterQueue, ILogTransport
11
+ from ...types import TransportHealth, TransportMetrics
12
+ from ...utils import internal_warn
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ResilientTransportConfig:
17
+ max_retries: int = 3
18
+ retry_delay: int = 1000
19
+
20
+
21
+ class ResilientTransport:
22
+ def __init__(
23
+ self,
24
+ transport: ILogTransport,
25
+ config: ResilientTransportConfig,
26
+ circuit_breaker: ICircuitBreaker | None = None,
27
+ dlq: IDeadLetterQueue | None = None,
28
+ ) -> None:
29
+ self._transport = transport
30
+ self._config = config
31
+ self._circuit_breaker = circuit_breaker
32
+ self._dlq = dlq
33
+ self._metrics: TransportMetrics = {
34
+ "total_sends": 0,
35
+ "successful_sends": 0,
36
+ "failed_sends": 0,
37
+ "avg_latency": 0.0,
38
+ "retry_attempts": 0,
39
+ "retried_requests": 0,
40
+ "dlq_dropped_entries": 0,
41
+ }
42
+ self._last_dlq_warning = 0.0
43
+
44
+ def send(self, entries: list[LogEntryEntity]) -> None:
45
+ if not entries:
46
+ return
47
+
48
+ self._metrics["total_sends"] = self._metrics.get("total_sends", 0) + 1
49
+
50
+ def operation() -> None:
51
+ self._retry_operation(lambda: self._transport.send(entries))
52
+
53
+ try:
54
+ if self._circuit_breaker is not None:
55
+ self._circuit_breaker.execute(operation)
56
+ else:
57
+ operation()
58
+ self._metrics["successful_sends"] = self._metrics.get("successful_sends", 0) + 1
59
+ except Exception as exc:
60
+ self._metrics["failed_sends"] = self._metrics.get("failed_sends", 0) + 1
61
+ self._enqueue_dlq(entries, exc)
62
+ raise
63
+
64
+ def _enqueue_dlq(self, entries: list[LogEntryEntity], error: Exception) -> None:
65
+ if self._dlq is None:
66
+ return
67
+ try:
68
+ result = self._dlq.add_batch(entries, str(error))
69
+ self._metrics["dlq_dropped_entries"] = self._metrics.get("dlq_dropped_entries", 0) + result.get(
70
+ "dropped", 0
71
+ )
72
+ except Exception as dlq_error:
73
+ now = time.time()
74
+ if now - self._last_dlq_warning > 10:
75
+ self._last_dlq_warning = now
76
+ internal_warn("Failed to enqueue logs to DLQ", dlq_error)
77
+
78
+ def _retry_operation(self, operation: Callable[[], None]) -> None:
79
+ max_retries = self._config.max_retries
80
+ delay = self._config.retry_delay
81
+ total_attempts = max_retries + 1
82
+ request_retried = False
83
+
84
+ for attempt in range(total_attempts):
85
+ try:
86
+ operation()
87
+ return
88
+ except Exception as exc:
89
+ should_retry = attempt < total_attempts - 1 and self._is_retryable_error(exc)
90
+ if not should_retry:
91
+ raise
92
+
93
+ if not request_retried:
94
+ request_retried = True
95
+ self._metrics["retried_requests"] = self._metrics.get("retried_requests", 0) + 1
96
+ self._metrics["retry_attempts"] = self._metrics.get("retry_attempts", 0) + 1
97
+
98
+ base_delay = delay * (2**attempt)
99
+ jitter = random.randint(0, 250)
100
+ time.sleep((base_delay + jitter) / 1000)
101
+
102
+ @staticmethod
103
+ def _is_retryable_error(error: Exception) -> bool:
104
+ retryable = getattr(error, "retryable", None)
105
+ if isinstance(retryable, bool):
106
+ return retryable
107
+
108
+ status_code = getattr(error, "status_code", None)
109
+ if isinstance(status_code, int):
110
+ return status_code == 429 or status_code >= 500
111
+
112
+ message = str(error).lower()
113
+ markers = [
114
+ "timeout",
115
+ "socket",
116
+ "connect",
117
+ "network",
118
+ "429",
119
+ "502",
120
+ "503",
121
+ "504",
122
+ ]
123
+ return any(marker in message for marker in markers)
124
+
125
+ def is_available(self) -> bool:
126
+ return self._transport.is_available()
127
+
128
+ def get_health(self) -> TransportHealth:
129
+ if self._circuit_breaker is not None:
130
+ state = self._circuit_breaker.get_state()
131
+ state_name = state.get("state")
132
+ if state_name == "open":
133
+ return {
134
+ "healthy": False,
135
+ "consecutive_failures": int(state.get("failures", 0)),
136
+ "error_message": f"CircuitBreaker is OPEN. Last error: {state.get('last_error')}",
137
+ }
138
+ if state_name == "half-open":
139
+ return {
140
+ "healthy": True,
141
+ "consecutive_failures": int(state.get("failures", 0)),
142
+ "error_message": "CircuitBreaker is HALF_OPEN",
143
+ }
144
+
145
+ return self._transport.get_health()
146
+
147
+ def get_metrics(self) -> TransportMetrics | None:
148
+ base = self._transport.get_metrics()
149
+ if base is None:
150
+ return cast(TransportMetrics, dict(self._metrics))
151
+
152
+ merged = dict(base)
153
+ merged["retry_attempts"] = base.get("retry_attempts", 0) + self._metrics.get("retry_attempts", 0)
154
+ merged["retried_requests"] = base.get("retried_requests", 0) + self._metrics.get("retried_requests", 0)
155
+ merged["dlq_dropped_entries"] = base.get("dlq_dropped_entries", 0) + self._metrics.get(
156
+ "dlq_dropped_entries", 0
157
+ )
158
+ return cast(TransportMetrics, merged)
159
+
160
+ def destroy(self) -> None:
161
+ self._transport.destroy()
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from ...config import ResolvedLogsInterceptorConfig
4
+ from ...domain.interfaces import ICircuitBreaker, IDeadLetterQueue, ILogTransport
5
+ from ...utils import internal_debug, internal_warn
6
+ from .loki_json_transport import LokiJsonTransport
7
+ from .loki_protobuf_transport import LokiProtobufTransport
8
+ from .resilient_transport import ResilientTransport, ResilientTransportConfig
9
+
10
+
11
+ class TransportFactory:
12
+ @staticmethod
13
+ def create(
14
+ config: ResolvedLogsInterceptorConfig,
15
+ circuit_breaker: ICircuitBreaker | None = None,
16
+ dlq: IDeadLetterQueue | None = None,
17
+ ) -> ILogTransport:
18
+ base_transport: ILogTransport
19
+
20
+ if config.transport.compression == "snappy":
21
+ try:
22
+ internal_debug("Selected LokiProtobufTransport")
23
+ base_transport = LokiProtobufTransport(config.transport)
24
+ except Exception as exc:
25
+ internal_warn("Falling back to LokiJsonTransport because snappy/protobuf is unavailable", exc)
26
+ base_transport = LokiJsonTransport(config.transport)
27
+ else:
28
+ internal_debug("Selected LokiJsonTransport")
29
+ base_transport = LokiJsonTransport(config.transport)
30
+
31
+ return ResilientTransport(
32
+ base_transport,
33
+ ResilientTransportConfig(
34
+ max_retries=config.transport.max_retries,
35
+ retry_delay=config.transport.retry_delay,
36
+ ),
37
+ circuit_breaker,
38
+ dlq,
39
+ )
@@ -0,0 +1,3 @@
1
+ from .worker_pool import WorkerMetrics, WorkerPool
2
+
3
+ __all__ = ["WorkerPool", "WorkerMetrics"]
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from concurrent.futures import Future, ThreadPoolExecutor
5
+ from dataclasses import dataclass
6
+ from threading import Lock
7
+ from typing import Any, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class WorkerMetrics:
14
+ active_workers: int
15
+ queue_length: int
16
+ total_tasks: int
17
+ completed_tasks: int
18
+ failed_tasks: int
19
+
20
+
21
+ class WorkerPool:
22
+ def __init__(self, max_workers: int | None = None) -> None:
23
+ self._executor = ThreadPoolExecutor(max_workers=max_workers)
24
+ self._lock = Lock()
25
+ self._total_tasks = 0
26
+ self._completed_tasks = 0
27
+ self._failed_tasks = 0
28
+
29
+ def execute(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> Future[T]:
30
+ with self._lock:
31
+ self._total_tasks += 1
32
+
33
+ future = self._executor.submit(fn, *args, **kwargs)
34
+
35
+ def _done_callback(done: Future[T]) -> None:
36
+ with self._lock:
37
+ if done.exception() is None:
38
+ self._completed_tasks += 1
39
+ else:
40
+ self._failed_tasks += 1
41
+
42
+ future.add_done_callback(_done_callback)
43
+ return future
44
+
45
+ def get_metrics(self) -> WorkerMetrics:
46
+ with self._lock:
47
+ queue_length = self._total_tasks - self._completed_tasks - self._failed_tasks
48
+ return WorkerMetrics(
49
+ active_workers=0,
50
+ queue_length=max(0, queue_length),
51
+ total_tasks=self._total_tasks,
52
+ completed_tasks=self._completed_tasks,
53
+ failed_tasks=self._failed_tasks,
54
+ )
55
+
56
+ def destroy(self) -> None:
57
+ self._executor.shutdown(wait=True, cancel_futures=True)
@@ -0,0 +1,17 @@
1
+ from .celery import CelerySignals
2
+ from .django import DjangoMiddleware
3
+ from .fastapi import FastAPIMiddleware
4
+ from .flask import FlaskExtension
5
+ from .logging_handler import LoggingHandler
6
+ from .loguru import LoguruSink
7
+ from .structlog import StructlogProcessor
8
+
9
+ __all__ = [
10
+ "LoggingHandler",
11
+ "FastAPIMiddleware",
12
+ "DjangoMiddleware",
13
+ "FlaskExtension",
14
+ "CelerySignals",
15
+ "StructlogProcessor",
16
+ "LoguruSink",
17
+ ]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..domain.interfaces import ILogger
6
+
7
+
8
+ class CelerySignals:
9
+ def __init__(self, logger: ILogger) -> None:
10
+ self.logger = logger
11
+
12
+ def register(self, app: Any) -> None:
13
+ try:
14
+ from celery import signals
15
+ except Exception as exc: # pragma: no cover - optional dependency
16
+ raise RuntimeError("celery extra is not installed") from exc
17
+
18
+ @signals.task_prerun.connect
19
+ def _task_prerun(task_id: str | None = None, task: Any = None, *args: Any, **kwargs: Any) -> None:
20
+ self.logger.info(
21
+ "Celery task started",
22
+ {
23
+ "source": "celery",
24
+ "task_id": task_id,
25
+ "task_name": getattr(task, "name", None),
26
+ },
27
+ )
28
+
29
+ @signals.task_postrun.connect
30
+ def _task_postrun(task_id: str | None = None, task: Any = None, retval: Any = None, *args: Any, **kwargs: Any) -> None:
31
+ self.logger.info(
32
+ "Celery task completed",
33
+ {
34
+ "source": "celery",
35
+ "task_id": task_id,
36
+ "task_name": getattr(task, "name", None),
37
+ "retval": str(retval),
38
+ },
39
+ )
40
+
41
+ @signals.task_failure.connect
42
+ def _task_failure(task_id: str | None = None, task: Any = None, exception: Exception | None = None, *args: Any, **kwargs: Any) -> None:
43
+ self.logger.error(
44
+ "Celery task failed",
45
+ {
46
+ "source": "celery",
47
+ "task_id": task_id,
48
+ "task_name": getattr(task, "name", None),
49
+ "error": str(exception),
50
+ },
51
+ )
52
+
53
+ app.log.get_default_logger()
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from ..domain.interfaces import ILogger
7
+
8
+
9
+ class DjangoMiddleware:
10
+ def __init__(self, get_response: Any, logger: ILogger) -> None:
11
+ self.get_response = get_response
12
+ self.logger = logger
13
+
14
+ def __call__(self, request: Any) -> Any:
15
+ start = time.time()
16
+ request_id = request.headers.get("X-Request-Id") if hasattr(request, "headers") else None
17
+ if not request_id:
18
+ request_id = f"req-{int(start * 1000)}"
19
+
20
+ def _run() -> Any:
21
+ return self.get_response(request)
22
+
23
+ response = self.logger.with_context({"request_id": request_id}, _run)
24
+
25
+ duration_ms = int((time.time() - start) * 1000)
26
+ status_code = int(getattr(response, "status_code", 200))
27
+ level = "info"
28
+ if status_code >= 500:
29
+ level = "error"
30
+ elif status_code >= 400:
31
+ level = "warn"
32
+
33
+ self.logger.log(
34
+ level, # type: ignore[arg-type]
35
+ f"{request.method} {request.path}",
36
+ {
37
+ "type": "http_request",
38
+ "source": "django",
39
+ "status_code": status_code,
40
+ "duration_ms": duration_ms,
41
+ },
42
+ )
43
+
44
+ return response
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from ..domain.interfaces import ILogger
7
+
8
+
9
+ class FastAPIMiddleware:
10
+ def __init__(self, app: Any, logger: ILogger) -> None:
11
+ self.app = app
12
+ self.logger = logger
13
+
14
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
15
+ if scope.get("type") != "http":
16
+ await self.app(scope, receive, send)
17
+ return
18
+
19
+ start = time.time()
20
+ headers = {k.decode("latin-1"): v.decode("latin-1") for k, v in scope.get("headers", [])}
21
+ request_id = headers.get("x-request-id") or f"req-{int(start * 1000)}"
22
+
23
+ status_code_holder = {"status": 200}
24
+
25
+ async def send_wrapper(message: dict[str, Any]) -> None:
26
+ if message.get("type") == "http.response.start":
27
+ status_code_holder["status"] = int(message.get("status", 200))
28
+ await send(message)
29
+
30
+ async def run_request() -> None:
31
+ await self.app(scope, receive, send_wrapper)
32
+
33
+ await self.logger.with_context_async({"request_id": request_id}, run_request)
34
+
35
+ duration_ms = int((time.time() - start) * 1000)
36
+ level = "info"
37
+ if status_code_holder["status"] >= 500:
38
+ level = "error"
39
+ elif status_code_holder["status"] >= 400:
40
+ level = "warn"
41
+
42
+ self.logger.log(
43
+ level, # type: ignore[arg-type]
44
+ f"{scope.get('method', 'GET')} {scope.get('path', '/')}",
45
+ {
46
+ "type": "http_request",
47
+ "path": scope.get("path"),
48
+ "method": scope.get("method"),
49
+ "status_code": status_code_holder["status"],
50
+ "duration_ms": duration_ms,
51
+ "source": "fastapi",
52
+ },
53
+ )