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,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from ...config import ResolvedCircuitBreakerConfig
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CircuitBreaker:
|
|
14
|
+
def __init__(self, config: ResolvedCircuitBreakerConfig) -> None:
|
|
15
|
+
self._config = config
|
|
16
|
+
self._state: str = "closed"
|
|
17
|
+
self._failures = 0
|
|
18
|
+
self._success_count = 0
|
|
19
|
+
self._half_open_in_flight = 0
|
|
20
|
+
self._last_failure: float | None = None
|
|
21
|
+
self._next_attempt: float | None = None
|
|
22
|
+
self._last_error: str | None = None
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
def execute(self, operation: Callable[[], T]) -> T:
|
|
26
|
+
if not self._config.enabled:
|
|
27
|
+
return operation()
|
|
28
|
+
|
|
29
|
+
with self._lock:
|
|
30
|
+
if self._is_open_locked():
|
|
31
|
+
raise RuntimeError("Circuit breaker is open")
|
|
32
|
+
counted_as_probe = self._state == "half-open"
|
|
33
|
+
if counted_as_probe and self._half_open_in_flight >= self._config.half_open_requests:
|
|
34
|
+
raise RuntimeError("Circuit breaker half-open probe limit reached")
|
|
35
|
+
if counted_as_probe:
|
|
36
|
+
self._half_open_in_flight += 1
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
result = operation()
|
|
40
|
+
self.record_success()
|
|
41
|
+
return result
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
self.record_failure(exc)
|
|
44
|
+
raise
|
|
45
|
+
finally:
|
|
46
|
+
if self._state == "half-open":
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._half_open_in_flight = max(0, self._half_open_in_flight - 1)
|
|
49
|
+
|
|
50
|
+
def record_success(self) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
if self._state == "half-open":
|
|
53
|
+
self._success_count += 1
|
|
54
|
+
if self._success_count >= self._config.half_open_requests:
|
|
55
|
+
self._state = "closed"
|
|
56
|
+
self._failures = 0
|
|
57
|
+
self._success_count = 0
|
|
58
|
+
self._half_open_in_flight = 0
|
|
59
|
+
self._last_error = None
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
if self._state == "closed":
|
|
63
|
+
self._failures = 0
|
|
64
|
+
self._last_error = None
|
|
65
|
+
|
|
66
|
+
def record_failure(self, error: Exception | None = None) -> None:
|
|
67
|
+
with self._lock:
|
|
68
|
+
self._failures += 1
|
|
69
|
+
self._last_failure = time.time()
|
|
70
|
+
if error is not None:
|
|
71
|
+
self._last_error = str(error)
|
|
72
|
+
|
|
73
|
+
if self._failures >= self._config.failure_threshold or self._state == "half-open":
|
|
74
|
+
self._state = "open"
|
|
75
|
+
self._success_count = 0
|
|
76
|
+
self._half_open_in_flight = 0
|
|
77
|
+
self._next_attempt = time.time() + (self._config.reset_timeout / 1000)
|
|
78
|
+
|
|
79
|
+
def get_state(self) -> dict[str, float | int | str | None]:
|
|
80
|
+
with self._lock:
|
|
81
|
+
return {
|
|
82
|
+
"state": self._state,
|
|
83
|
+
"failures": self._failures,
|
|
84
|
+
"success_count": self._success_count,
|
|
85
|
+
"half_open_in_flight": self._half_open_in_flight,
|
|
86
|
+
"last_failure": self._last_failure,
|
|
87
|
+
"next_attempt": self._next_attempt,
|
|
88
|
+
"last_error": self._last_error,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def reset(self) -> None:
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._state = "closed"
|
|
94
|
+
self._failures = 0
|
|
95
|
+
self._success_count = 0
|
|
96
|
+
self._half_open_in_flight = 0
|
|
97
|
+
self._last_failure = None
|
|
98
|
+
self._next_attempt = None
|
|
99
|
+
self._last_error = None
|
|
100
|
+
|
|
101
|
+
def _is_open_locked(self) -> bool:
|
|
102
|
+
if self._state != "open":
|
|
103
|
+
return False
|
|
104
|
+
now = time.time()
|
|
105
|
+
if self._next_attempt and now >= self._next_attempt:
|
|
106
|
+
self._state = "half-open"
|
|
107
|
+
self._success_count = 0
|
|
108
|
+
self._half_open_in_flight = 0
|
|
109
|
+
return False
|
|
110
|
+
return True
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .base import Compressor, CompressorConfig
|
|
2
|
+
from .brotli_compressor import BrotliCompressor
|
|
3
|
+
from .factory import CompressorFactory
|
|
4
|
+
from .gzip_compressor import GzipCompressor
|
|
5
|
+
from .noop_compressor import NoOpCompressor
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Compressor",
|
|
9
|
+
"CompressorConfig",
|
|
10
|
+
"CompressorFactory",
|
|
11
|
+
"GzipCompressor",
|
|
12
|
+
"BrotliCompressor",
|
|
13
|
+
"NoOpCompressor",
|
|
14
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class CompressorConfig:
|
|
8
|
+
level: int | None = None
|
|
9
|
+
threshold: int | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Compressor:
|
|
13
|
+
def compress(self, data: bytes) -> bytes:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
def get_content_encoding(self) -> str:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
def get_name(self) -> str:
|
|
20
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from .base import Compressor, CompressorConfig
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import brotli # type: ignore[import-not-found]
|
|
9
|
+
except Exception: # pragma: no cover - optional dependency
|
|
10
|
+
brotli = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BrotliCompressor(Compressor):
|
|
14
|
+
def __init__(self, config: CompressorConfig | None = None) -> None:
|
|
15
|
+
self._config = config or CompressorConfig()
|
|
16
|
+
self._level = 4 if self._config.level is None else self._config.level
|
|
17
|
+
|
|
18
|
+
def compress(self, data: bytes) -> bytes:
|
|
19
|
+
if brotli is None:
|
|
20
|
+
raise RuntimeError("brotli extra is not installed")
|
|
21
|
+
return cast(bytes, brotli.compress(data, quality=self._level))
|
|
22
|
+
|
|
23
|
+
def get_content_encoding(self) -> str:
|
|
24
|
+
return "br"
|
|
25
|
+
|
|
26
|
+
def get_name(self) -> str:
|
|
27
|
+
return "brotli"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import Compressor, CompressorConfig
|
|
4
|
+
from .brotli_compressor import BrotliCompressor
|
|
5
|
+
from .gzip_compressor import GzipCompressor
|
|
6
|
+
from .noop_compressor import NoOpCompressor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CompressorFactory:
|
|
10
|
+
@staticmethod
|
|
11
|
+
def create(type_name: str | bool | None, config: CompressorConfig | None = None) -> Compressor:
|
|
12
|
+
if type_name in (False, "none"):
|
|
13
|
+
return NoOpCompressor()
|
|
14
|
+
if type_name in (True, None, "gzip"):
|
|
15
|
+
return GzipCompressor(config)
|
|
16
|
+
if type_name == "brotli":
|
|
17
|
+
return BrotliCompressor(config)
|
|
18
|
+
return GzipCompressor(config)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
|
|
5
|
+
from .base import Compressor, CompressorConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GzipCompressor(Compressor):
|
|
9
|
+
def __init__(self, config: CompressorConfig | None = None) -> None:
|
|
10
|
+
self._config = config or CompressorConfig()
|
|
11
|
+
self._level = 6 if self._config.level is None else self._config.level
|
|
12
|
+
|
|
13
|
+
def compress(self, data: bytes) -> bytes:
|
|
14
|
+
return gzip.compress(data, compresslevel=self._level)
|
|
15
|
+
|
|
16
|
+
def get_content_encoding(self) -> str:
|
|
17
|
+
return "gzip"
|
|
18
|
+
|
|
19
|
+
def get_name(self) -> str:
|
|
20
|
+
return "gzip"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .base import Compressor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NoOpCompressor(Compressor):
|
|
7
|
+
def compress(self, data: bytes) -> bytes:
|
|
8
|
+
return data
|
|
9
|
+
|
|
10
|
+
def get_content_encoding(self) -> str:
|
|
11
|
+
return ""
|
|
12
|
+
|
|
13
|
+
def get_name(self) -> str:
|
|
14
|
+
return "none"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ContextVarProvider:
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self._context: ContextVar[dict[str, Any] | None] = ContextVar(
|
|
10
|
+
"logs_interceptor_context", default=None
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def get_context(self) -> dict[str, Any]:
|
|
14
|
+
return dict(self._context.get() or {})
|
|
15
|
+
|
|
16
|
+
def run_with_context(self, context: dict[str, Any], fn: Any) -> Any:
|
|
17
|
+
merged = {**(self._context.get() or {}), **context}
|
|
18
|
+
token = self._context.set(merged)
|
|
19
|
+
try:
|
|
20
|
+
return fn()
|
|
21
|
+
finally:
|
|
22
|
+
self._context.reset(token)
|
|
23
|
+
|
|
24
|
+
async def run_with_context_async(self, context: dict[str, Any], fn: Any) -> Any:
|
|
25
|
+
merged = {**(self._context.get() or {}), **context}
|
|
26
|
+
token = self._context.set(merged)
|
|
27
|
+
try:
|
|
28
|
+
result = fn()
|
|
29
|
+
if hasattr(result, "__await__"):
|
|
30
|
+
return await result
|
|
31
|
+
return result
|
|
32
|
+
finally:
|
|
33
|
+
self._context.reset(token)
|
|
34
|
+
|
|
35
|
+
def set(self, key: str, value: Any) -> None:
|
|
36
|
+
merged = self.get_context()
|
|
37
|
+
merged[key] = value
|
|
38
|
+
self._context.set(merged)
|
|
39
|
+
|
|
40
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
41
|
+
return (self._context.get() or {}).get(key, default)
|
|
42
|
+
|
|
43
|
+
def clear(self) -> None:
|
|
44
|
+
self._context.set({})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ...domain.entities import LogEntryEntity
|
|
11
|
+
from ...utils import internal_warn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class _DLQEntry:
|
|
16
|
+
entry: LogEntryEntity
|
|
17
|
+
reason: str
|
|
18
|
+
timestamp: float
|
|
19
|
+
retry_count: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileDeadLetterQueue:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_path: str | None = None,
|
|
26
|
+
max_size: int = 1000,
|
|
27
|
+
max_file_size_mb: int = 10,
|
|
28
|
+
max_retries: int = 3,
|
|
29
|
+
) -> None:
|
|
30
|
+
path = Path(base_path or os.getcwd())
|
|
31
|
+
self._dlq_dir = path / ".logs-interceptor-dlq"
|
|
32
|
+
self._dlq_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
self._file_path = self._dlq_dir / "dlq-current.jsonl"
|
|
35
|
+
self._max_size = max_size
|
|
36
|
+
self._max_file_size_mb = max_file_size_mb
|
|
37
|
+
self._max_retries = max_retries
|
|
38
|
+
self._dropped_entries = 0
|
|
39
|
+
self._queue: list[_DLQEntry] = []
|
|
40
|
+
self._lock = threading.RLock()
|
|
41
|
+
|
|
42
|
+
self.load_from_disk()
|
|
43
|
+
|
|
44
|
+
def add(self, entry: LogEntryEntity, reason: str) -> dict[str, int]:
|
|
45
|
+
return self.add_batch([entry], reason)
|
|
46
|
+
|
|
47
|
+
def add_batch(self, entries: list[LogEntryEntity], reason: str) -> dict[str, int]:
|
|
48
|
+
if not entries:
|
|
49
|
+
return {"added": 0, "dropped": 0}
|
|
50
|
+
|
|
51
|
+
with self._lock:
|
|
52
|
+
dropped = 0
|
|
53
|
+
ts = time.time()
|
|
54
|
+
for entry in entries:
|
|
55
|
+
if len(self._queue) >= self._max_size:
|
|
56
|
+
self._queue.pop(0)
|
|
57
|
+
self._dropped_entries += 1
|
|
58
|
+
dropped += 1
|
|
59
|
+
self._queue.append(_DLQEntry(entry=entry, reason=reason, timestamp=ts, retry_count=0))
|
|
60
|
+
|
|
61
|
+
self._persist_queue_to_disk()
|
|
62
|
+
return {"added": len(entries), "dropped": dropped}
|
|
63
|
+
|
|
64
|
+
def flush(self) -> int:
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
def size(self) -> int:
|
|
68
|
+
with self._lock:
|
|
69
|
+
return len(self._queue)
|
|
70
|
+
|
|
71
|
+
def clear(self) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._queue.clear()
|
|
74
|
+
try:
|
|
75
|
+
if self._file_path.exists():
|
|
76
|
+
self._file_path.unlink()
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
internal_warn("[FileDLQ] Failed to clear file", exc)
|
|
79
|
+
|
|
80
|
+
def get_entries(self, limit: int = 100) -> list[dict[str, object]]:
|
|
81
|
+
with self._lock:
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
"entry": item.entry,
|
|
85
|
+
"reason": item.reason,
|
|
86
|
+
"timestamp": item.timestamp,
|
|
87
|
+
}
|
|
88
|
+
for item in self._queue[:limit]
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def get_stats(self) -> dict[str, int]:
|
|
92
|
+
with self._lock:
|
|
93
|
+
return {"size": len(self._queue), "dropped_entries": self._dropped_entries}
|
|
94
|
+
|
|
95
|
+
def load_from_disk(self) -> int:
|
|
96
|
+
if not self._file_path.exists():
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
with self._lock:
|
|
100
|
+
try:
|
|
101
|
+
lines = self._file_path.read_text(encoding="utf-8").splitlines()
|
|
102
|
+
loaded: list[_DLQEntry] = []
|
|
103
|
+
for line in lines:
|
|
104
|
+
if not line.strip():
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
item = json.loads(line)
|
|
108
|
+
retry_count = int(item.get("retry_count", 0))
|
|
109
|
+
if retry_count > self._max_retries:
|
|
110
|
+
continue
|
|
111
|
+
entry_payload = item.get("entry", {})
|
|
112
|
+
if not isinstance(entry_payload, dict):
|
|
113
|
+
continue
|
|
114
|
+
loaded.append(
|
|
115
|
+
_DLQEntry(
|
|
116
|
+
entry=LogEntryEntity(
|
|
117
|
+
id=str(entry_payload.get("id", "")),
|
|
118
|
+
timestamp=str(entry_payload.get("timestamp", "")),
|
|
119
|
+
level=str(entry_payload.get("level", "info")), # type: ignore[arg-type]
|
|
120
|
+
message=str(entry_payload.get("message", "")),
|
|
121
|
+
context=entry_payload.get("context"),
|
|
122
|
+
trace_id=entry_payload.get("trace_id"),
|
|
123
|
+
span_id=entry_payload.get("span_id"),
|
|
124
|
+
request_id=entry_payload.get("request_id"),
|
|
125
|
+
labels=entry_payload.get("labels"),
|
|
126
|
+
metadata=entry_payload.get("metadata"),
|
|
127
|
+
),
|
|
128
|
+
reason=str(item.get("reason", "unknown")),
|
|
129
|
+
timestamp=float(item.get("timestamp", time.time())),
|
|
130
|
+
retry_count=retry_count,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
except Exception:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if len(loaded) > self._max_size:
|
|
137
|
+
self._dropped_entries += len(loaded) - self._max_size
|
|
138
|
+
self._queue = loaded[-self._max_size :]
|
|
139
|
+
return len(self._queue)
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
internal_warn("[FileDLQ] Failed to load from disk", exc)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
def _persist_queue_to_disk(self) -> None:
|
|
145
|
+
lines = [
|
|
146
|
+
json.dumps(
|
|
147
|
+
{
|
|
148
|
+
"entry": item.entry.to_dict(),
|
|
149
|
+
"reason": item.reason,
|
|
150
|
+
"timestamp": item.timestamp,
|
|
151
|
+
"retry_count": item.retry_count,
|
|
152
|
+
},
|
|
153
|
+
ensure_ascii=False,
|
|
154
|
+
separators=(",", ":"),
|
|
155
|
+
)
|
|
156
|
+
for item in self._queue
|
|
157
|
+
]
|
|
158
|
+
content = "\n".join(lines)
|
|
159
|
+
if content:
|
|
160
|
+
content += "\n"
|
|
161
|
+
|
|
162
|
+
max_bytes = self._max_file_size_mb * 1024 * 1024
|
|
163
|
+
if len(content.encode("utf-8")) > max_bytes and self._queue:
|
|
164
|
+
half = max(1, len(self._queue) // 2)
|
|
165
|
+
dropped = len(self._queue) - half
|
|
166
|
+
self._queue = self._queue[-half:]
|
|
167
|
+
self._dropped_entries += dropped
|
|
168
|
+
return self._persist_queue_to_disk()
|
|
169
|
+
|
|
170
|
+
self._file_path.write_text(content, encoding="utf-8")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from ...domain.entities import LogEntryEntity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class _DLQEntry:
|
|
11
|
+
entry: LogEntryEntity
|
|
12
|
+
reason: str
|
|
13
|
+
timestamp: float
|
|
14
|
+
retry_count: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MemoryDeadLetterQueue:
|
|
18
|
+
def __init__(self, max_size: int = 1000) -> None:
|
|
19
|
+
self._queue: list[_DLQEntry] = []
|
|
20
|
+
self._max_size = max_size
|
|
21
|
+
self._dropped_entries = 0
|
|
22
|
+
|
|
23
|
+
def add(self, entry: LogEntryEntity, reason: str) -> dict[str, int]:
|
|
24
|
+
return self.add_batch([entry], reason)
|
|
25
|
+
|
|
26
|
+
def add_batch(self, entries: list[LogEntryEntity], reason: str) -> dict[str, int]:
|
|
27
|
+
dropped = 0
|
|
28
|
+
timestamp = time.time()
|
|
29
|
+
for entry in entries:
|
|
30
|
+
if len(self._queue) >= self._max_size:
|
|
31
|
+
self._queue.pop(0)
|
|
32
|
+
self._dropped_entries += 1
|
|
33
|
+
dropped += 1
|
|
34
|
+
self._queue.append(_DLQEntry(entry=entry, reason=reason, timestamp=timestamp, retry_count=0))
|
|
35
|
+
return {"added": len(entries), "dropped": dropped}
|
|
36
|
+
|
|
37
|
+
def flush(self) -> int:
|
|
38
|
+
count = len(self._queue)
|
|
39
|
+
self._queue.clear()
|
|
40
|
+
return count
|
|
41
|
+
|
|
42
|
+
def size(self) -> int:
|
|
43
|
+
return len(self._queue)
|
|
44
|
+
|
|
45
|
+
def clear(self) -> None:
|
|
46
|
+
self._queue.clear()
|
|
47
|
+
|
|
48
|
+
def get_entries(self, limit: int = 100) -> list[dict[str, object]]:
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
"entry": item.entry,
|
|
52
|
+
"reason": item.reason,
|
|
53
|
+
"timestamp": item.timestamp,
|
|
54
|
+
}
|
|
55
|
+
for item in self._queue[:limit]
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def get_stats(self) -> dict[str, int]:
|
|
59
|
+
return {"size": len(self._queue), "dropped_entries": self._dropped_entries}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ...config import ResolvedFilterConfig
|
|
6
|
+
from ...domain.entities import LogEntryEntity
|
|
7
|
+
from ...types import LogLevel
|
|
8
|
+
from ...utils import detect_sensitive_data, sanitize_data, should_sample
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LogFilter:
|
|
12
|
+
def __init__(self, config: ResolvedFilterConfig) -> None:
|
|
13
|
+
self._config = config
|
|
14
|
+
self._patterns = [re.compile(pattern) for pattern in config.patterns]
|
|
15
|
+
|
|
16
|
+
def should_process(self, entry: LogEntryEntity) -> bool:
|
|
17
|
+
if not self.is_level_enabled(entry.level):
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
if self._patterns:
|
|
21
|
+
if not any(pattern.search(entry.message) for pattern in self._patterns):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
if not should_sample(self._config.sampling_rate):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
def filter(self, entry: LogEntryEntity) -> LogEntryEntity:
|
|
30
|
+
message = entry.message
|
|
31
|
+
if len(message) > self._config.max_message_length:
|
|
32
|
+
message = message[: self._config.max_message_length] + "...[truncated]"
|
|
33
|
+
|
|
34
|
+
context = entry.context
|
|
35
|
+
if self._config.sanitize and context is not None:
|
|
36
|
+
context = sanitize_data(context, self._config.sensitive_patterns)
|
|
37
|
+
|
|
38
|
+
if self._config.sanitize and detect_sensitive_data(message, self._config.sensitive_patterns):
|
|
39
|
+
message = "[REDACTED]"
|
|
40
|
+
|
|
41
|
+
return LogEntryEntity(
|
|
42
|
+
id=entry.id,
|
|
43
|
+
timestamp=entry.timestamp,
|
|
44
|
+
level=entry.level,
|
|
45
|
+
message=message,
|
|
46
|
+
context=context,
|
|
47
|
+
trace_id=entry.trace_id,
|
|
48
|
+
span_id=entry.span_id,
|
|
49
|
+
request_id=entry.request_id,
|
|
50
|
+
labels=entry.labels,
|
|
51
|
+
metadata=entry.metadata,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def is_level_enabled(self, level: LogLevel) -> bool:
|
|
55
|
+
return level in self._config.levels
|