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,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,3 @@
1
+ from .context_provider import ContextVarProvider
2
+
3
+ __all__ = ["ContextVarProvider"]
@@ -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,4 @@
1
+ from .file_dlq import FileDeadLetterQueue
2
+ from .memory_dlq import MemoryDeadLetterQueue
3
+
4
+ __all__ = ["MemoryDeadLetterQueue", "FileDeadLetterQueue"]
@@ -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,3 @@
1
+ from .log_filter import LogFilter
2
+
3
+ __all__ = ["LogFilter"]
@@ -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
@@ -0,0 +1,3 @@
1
+ from .runtime_interceptor import RuntimeInterceptor
2
+
3
+ __all__ = ["RuntimeInterceptor"]