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.
- elven_logs_interceptor_python-0.1.2.dist-info/METADATA +262 -0
- elven_logs_interceptor_python-0.1.2.dist-info/RECORD +56 -0
- elven_logs_interceptor_python-0.1.2.dist-info/WHEEL +4 -0
- logs_interceptor/__init__.py +333 -0
- logs_interceptor/application/__init__.py +27 -0
- logs_interceptor/application/config_service.py +232 -0
- logs_interceptor/application/log_service.py +383 -0
- logs_interceptor/config.py +190 -0
- logs_interceptor/domain/__init__.py +25 -0
- logs_interceptor/domain/entities.py +41 -0
- logs_interceptor/domain/interfaces.py +149 -0
- logs_interceptor/domain/value_objects.py +40 -0
- logs_interceptor/infrastructure/__init__.py +48 -0
- logs_interceptor/infrastructure/buffer/__init__.py +3 -0
- logs_interceptor/infrastructure/buffer/memory_buffer.py +187 -0
- logs_interceptor/infrastructure/circuit_breaker/__init__.py +3 -0
- logs_interceptor/infrastructure/circuit_breaker/circuit_breaker.py +110 -0
- logs_interceptor/infrastructure/compression/__init__.py +14 -0
- logs_interceptor/infrastructure/compression/base.py +20 -0
- logs_interceptor/infrastructure/compression/brotli_compressor.py +27 -0
- logs_interceptor/infrastructure/compression/factory.py +18 -0
- logs_interceptor/infrastructure/compression/gzip_compressor.py +20 -0
- logs_interceptor/infrastructure/compression/noop_compressor.py +14 -0
- logs_interceptor/infrastructure/context/__init__.py +3 -0
- logs_interceptor/infrastructure/context/context_provider.py +44 -0
- logs_interceptor/infrastructure/dlq/__init__.py +4 -0
- logs_interceptor/infrastructure/dlq/file_dlq.py +170 -0
- logs_interceptor/infrastructure/dlq/memory_dlq.py +59 -0
- logs_interceptor/infrastructure/filter/__init__.py +3 -0
- logs_interceptor/infrastructure/filter/log_filter.py +55 -0
- logs_interceptor/infrastructure/interceptors/__init__.py +3 -0
- logs_interceptor/infrastructure/interceptors/runtime_interceptor.py +139 -0
- logs_interceptor/infrastructure/memory/__init__.py +3 -0
- logs_interceptor/infrastructure/memory/memory_tracker.py +95 -0
- logs_interceptor/infrastructure/metrics/__init__.py +3 -0
- logs_interceptor/infrastructure/metrics/metrics_collector.py +104 -0
- logs_interceptor/infrastructure/transport/__init__.py +12 -0
- logs_interceptor/infrastructure/transport/loki_json_transport.py +226 -0
- logs_interceptor/infrastructure/transport/loki_protobuf_transport.py +209 -0
- logs_interceptor/infrastructure/transport/resilient_transport.py +161 -0
- logs_interceptor/infrastructure/transport/transport_factory.py +39 -0
- logs_interceptor/infrastructure/workers/__init__.py +3 -0
- logs_interceptor/infrastructure/workers/worker_pool.py +57 -0
- logs_interceptor/integrations/__init__.py +17 -0
- logs_interceptor/integrations/celery.py +53 -0
- logs_interceptor/integrations/django.py +44 -0
- logs_interceptor/integrations/fastapi.py +53 -0
- logs_interceptor/integrations/flask.py +50 -0
- logs_interceptor/integrations/logging_handler.py +43 -0
- logs_interceptor/integrations/loguru.py +36 -0
- logs_interceptor/integrations/structlog.py +21 -0
- logs_interceptor/preload.py +61 -0
- logs_interceptor/presentation/__init__.py +3 -0
- logs_interceptor/presentation/factory.py +128 -0
- logs_interceptor/types.py +89 -0
- 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,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
|
+
)
|