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,50 @@
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 FlaskExtension:
10
+ def __init__(self, logger: ILogger) -> None:
11
+ self.logger = logger
12
+
13
+ def init_app(self, app: Any) -> None:
14
+ @app.before_request
15
+ def _before_request() -> None:
16
+ from flask import g, request
17
+
18
+ g._logs_interceptor_start_time = time.time()
19
+ request_id = request.headers.get("X-Request-Id") or f"req-{int(time.time() * 1000)}"
20
+ g._logs_interceptor_request_id = request_id
21
+
22
+ @app.after_request
23
+ def _after_request(response: Any) -> Any:
24
+ from flask import g, request
25
+
26
+ start = getattr(g, "_logs_interceptor_start_time", time.time())
27
+ request_id = getattr(g, "_logs_interceptor_request_id", "")
28
+ duration_ms = int((time.time() - start) * 1000)
29
+ status_code = int(getattr(response, "status_code", 200))
30
+
31
+ level = "info"
32
+ if status_code >= 500:
33
+ level = "error"
34
+ elif status_code >= 400:
35
+ level = "warn"
36
+
37
+ self.logger.with_context(
38
+ {"request_id": request_id},
39
+ lambda: self.logger.log(
40
+ level, # type: ignore[arg-type]
41
+ f"{request.method} {request.path}",
42
+ {
43
+ "source": "flask",
44
+ "type": "http_request",
45
+ "status_code": status_code,
46
+ "duration_ms": duration_ms,
47
+ },
48
+ ),
49
+ )
50
+ return response
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import traceback as traceback_module
5
+ from typing import Any
6
+
7
+ from ..domain.interfaces import ILogger
8
+ from ..types import LogLevel
9
+
10
+
11
+ class LoggingHandler(logging.Handler):
12
+ def __init__(self, logger: ILogger) -> None:
13
+ super().__init__()
14
+ self._logger = logger
15
+
16
+ def emit(self, record: logging.LogRecord) -> None:
17
+ try:
18
+ level = self._map_level(record.levelno)
19
+ message = record.getMessage()
20
+ context: dict[str, Any] = {
21
+ "source": "python-logging",
22
+ "logger_name": record.name,
23
+ "module": record.module,
24
+ "function": record.funcName,
25
+ "line": record.lineno,
26
+ }
27
+ if record.exc_info:
28
+ context["exception"] = "".join(traceback_module.format_exception(*record.exc_info))
29
+ self._logger.log(level, message, context)
30
+ except Exception:
31
+ return
32
+
33
+ @staticmethod
34
+ def _map_level(level_no: int) -> LogLevel:
35
+ if level_no >= logging.CRITICAL:
36
+ return "fatal"
37
+ if level_no >= logging.ERROR:
38
+ return "error"
39
+ if level_no >= logging.WARNING:
40
+ return "warn"
41
+ if level_no >= logging.INFO:
42
+ return "info"
43
+ return "debug"
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..domain.interfaces import ILogger
6
+
7
+
8
+ class LoguruSink:
9
+ def __init__(self, logger: ILogger) -> None:
10
+ self.logger = logger
11
+
12
+ def __call__(self, message: Any) -> None:
13
+ record = getattr(message, "record", None)
14
+ if record is None:
15
+ self.logger.info(str(message), {"source": "loguru"})
16
+ return
17
+
18
+ level = str(record.get("level", {}).get("name", "INFO")).lower()
19
+ if level == "warning":
20
+ level = "warn"
21
+ if level == "critical":
22
+ level = "fatal"
23
+ if level not in {"debug", "info", "warn", "error", "fatal"}:
24
+ level = "info"
25
+
26
+ self.logger.log(
27
+ level, # type: ignore[arg-type]
28
+ str(record.get("message", "")),
29
+ {
30
+ "source": "loguru",
31
+ "module": record.get("module"),
32
+ "function": record.get("function"),
33
+ "line": record.get("line"),
34
+ "extra": record.get("extra"),
35
+ },
36
+ )
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..domain.interfaces import ILogger
6
+
7
+
8
+ class StructlogProcessor:
9
+ def __init__(self, logger: ILogger) -> None:
10
+ self.logger = logger
11
+
12
+ def __call__(self, _logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
13
+ message = str(event_dict.get("event", method_name))
14
+ level = method_name.lower()
15
+ if level not in {"debug", "info", "warn", "error", "fatal"}:
16
+ level = "info"
17
+
18
+ context = dict(event_dict)
19
+ context.pop("event", None)
20
+ self.logger.log(level, message, {"source": "structlog", **context}) # type: ignore[arg-type]
21
+ return event_dict
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import os
5
+ import signal
6
+ import sys
7
+
8
+ _LOGS_INTERCEPTOR_PRELOADED = False
9
+
10
+
11
+ def _debug(*args: object) -> None:
12
+ if os.getenv("LOGS_DEBUG") == "true" and os.getenv("LOGS_SILENT_ERRORS") != "true":
13
+ print("[logs-interceptor:preload]", *args)
14
+
15
+
16
+ def _error(*args: object) -> None:
17
+ if os.getenv("LOGS_SILENT_ERRORS") != "true":
18
+ print("[logs-interceptor:preload]", *args, file=sys.stderr)
19
+
20
+
21
+ def _install() -> None:
22
+ global _LOGS_INTERCEPTOR_PRELOADED
23
+ if _LOGS_INTERCEPTOR_PRELOADED:
24
+ return
25
+
26
+ _LOGS_INTERCEPTOR_PRELOADED = True
27
+
28
+ if os.getenv("LOGS_ENABLED") == "false":
29
+ _debug("Disabled by LOGS_ENABLED=false")
30
+ return
31
+
32
+ try:
33
+ os.environ["LOGS_AUTO_INIT"] = "true"
34
+ from . import destroy, is_initialized
35
+
36
+ if is_initialized():
37
+ _debug("Initialized successfully via auto-init gate")
38
+ else:
39
+ _debug("Auto-init did not run (missing required LOGS_* variables)")
40
+
41
+ def _graceful_shutdown(signame: str) -> None:
42
+ _debug(f"Graceful shutdown triggered by {signame}")
43
+ try:
44
+ destroy()
45
+ except Exception as exc:
46
+ _error("Graceful shutdown failed:", exc)
47
+
48
+ def _sigterm_handler(_sig: int, _frame: object) -> None:
49
+ _graceful_shutdown("SIGTERM")
50
+
51
+ def _sigint_handler(_sig: int, _frame: object) -> None:
52
+ _graceful_shutdown("SIGINT")
53
+
54
+ signal.signal(signal.SIGTERM, _sigterm_handler)
55
+ signal.signal(signal.SIGINT, _sigint_handler)
56
+ atexit.register(lambda: _graceful_shutdown("atexit"))
57
+ except Exception as exc:
58
+ _error("Preload failed:", exc)
59
+
60
+
61
+ _install()
@@ -0,0 +1,3 @@
1
+ from .factory import LogsInterceptorFactory, RuntimeBundle
2
+
3
+ __all__ = ["LogsInterceptorFactory", "RuntimeBundle"]
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from ..application.log_service import LogService
8
+ from ..config import ResolvedLogsInterceptorConfig
9
+ from ..domain.interfaces import ILogger
10
+ from ..infrastructure.buffer import MemoryBuffer
11
+ from ..infrastructure.circuit_breaker import CircuitBreaker
12
+ from ..infrastructure.context import ContextVarProvider
13
+ from ..infrastructure.dlq import FileDeadLetterQueue, MemoryDeadLetterQueue
14
+ from ..infrastructure.filter import LogFilter
15
+ from ..infrastructure.interceptors import RuntimeInterceptor
16
+ from ..infrastructure.transport import TransportFactory
17
+
18
+ otel_trace: Any = None
19
+ try:
20
+ from opentelemetry import trace as otel_trace
21
+ except Exception: # pragma: no cover - optional dependency
22
+ pass
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class RuntimeBundle:
27
+ logger: ILogger
28
+ runtime_interceptor: RuntimeInterceptor | None = None
29
+
30
+
31
+ class LogsInterceptorFactory:
32
+ @staticmethod
33
+ def create(config: ResolvedLogsInterceptorConfig) -> RuntimeBundle:
34
+ context_provider = ContextVarProvider()
35
+
36
+ dynamic_labels: dict[str, Callable[[], str | int]] = {
37
+ "request_id": lambda: str(context_provider.get("request_id", "")),
38
+ }
39
+
40
+ if otel_trace is not None:
41
+ dynamic_labels["trace_id"] = lambda: LogsInterceptorFactory._trace_id()
42
+ dynamic_labels["span_id"] = lambda: LogsInterceptorFactory._span_id()
43
+
44
+ dynamic_labels.update(config.dynamic_labels)
45
+
46
+ circuit_breaker = CircuitBreaker(config.circuit_breaker)
47
+
48
+ dlq: FileDeadLetterQueue | MemoryDeadLetterQueue | None = None
49
+ dlq_cfg = config.dead_letter_queue
50
+ if dlq_cfg and dlq_cfg.enabled is not False:
51
+ dlq_type = dlq_cfg.type or "memory"
52
+ if dlq_type == "file":
53
+ dlq = FileDeadLetterQueue(
54
+ base_path=dlq_cfg.base_path,
55
+ max_size=dlq_cfg.max_size or 1000,
56
+ max_retries=dlq_cfg.max_retries or 3,
57
+ max_file_size_mb=dlq_cfg.max_file_size_mb or 10,
58
+ )
59
+ else:
60
+ dlq = MemoryDeadLetterQueue(dlq_cfg.max_size or 1000)
61
+
62
+ transport = TransportFactory.create(config, circuit_breaker, dlq)
63
+ buffer = MemoryBuffer(config.buffer)
64
+ filter_service = LogFilter(config.filter)
65
+
66
+ logger = LogService(
67
+ filter_service,
68
+ buffer,
69
+ transport,
70
+ context_provider,
71
+ {
72
+ "app_name": config.app_name,
73
+ "version": config.version,
74
+ "environment": config.environment,
75
+ "labels": config.labels,
76
+ "dynamic_labels": dynamic_labels,
77
+ "enable_metrics": config.enable_metrics,
78
+ "max_concurrent_flushes": config.performance.max_concurrent_flushes,
79
+ },
80
+ )
81
+
82
+ runtime_interceptor = None
83
+ if config.intercept_console:
84
+ runtime_interceptor = RuntimeInterceptor(logger, config.preserve_original_console)
85
+ runtime_interceptor.enable()
86
+
87
+ original_destroy = logger.destroy
88
+
89
+ def wrapped_destroy() -> None:
90
+ if runtime_interceptor is not None:
91
+ runtime_interceptor.restore()
92
+ original_destroy()
93
+
94
+ logger.destroy = wrapped_destroy # type: ignore[method-assign]
95
+
96
+ return RuntimeBundle(logger=logger, runtime_interceptor=runtime_interceptor)
97
+
98
+ @staticmethod
99
+ def _trace_id() -> str:
100
+ if otel_trace is None:
101
+ return ""
102
+ try:
103
+ span = otel_trace.get_current_span()
104
+ if span is None:
105
+ return ""
106
+ ctx = span.get_span_context()
107
+ trace_id = getattr(ctx, "trace_id", None)
108
+ if isinstance(trace_id, int):
109
+ return f"{trace_id:032x}"
110
+ return str(trace_id or "")
111
+ except Exception:
112
+ return ""
113
+
114
+ @staticmethod
115
+ def _span_id() -> str:
116
+ if otel_trace is None:
117
+ return ""
118
+ try:
119
+ span = otel_trace.get_current_span()
120
+ if span is None:
121
+ return ""
122
+ ctx = span.get_span_context()
123
+ span_id = getattr(ctx, "span_id", None)
124
+ if isinstance(span_id, int):
125
+ return f"{span_id:016x}"
126
+ return str(span_id or "")
127
+ except Exception:
128
+ return ""
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal, TypedDict
5
+
6
+ LogLevel = Literal["debug", "info", "warn", "error", "fatal"]
7
+ CircuitBreakerState = Literal["closed", "open", "half-open"]
8
+
9
+
10
+ class LogEntry(TypedDict, total=False):
11
+ id: str
12
+ timestamp: str
13
+ level: LogLevel
14
+ message: str
15
+ context: dict[str, Any]
16
+ trace_id: str
17
+ span_id: str
18
+ request_id: str
19
+ labels: dict[str, str]
20
+ metadata: dict[str, Any]
21
+
22
+
23
+ class TransportHealth(TypedDict, total=False):
24
+ healthy: bool
25
+ last_successful_send: float
26
+ consecutive_failures: int
27
+ error_message: str
28
+
29
+
30
+ class TransportMetrics(TypedDict, total=False):
31
+ total_sends: int
32
+ successful_sends: int
33
+ failed_sends: int
34
+ avg_latency: float
35
+ avg_compression_time: float
36
+ avg_compression_ratio: float
37
+ total_bytes_sent: int
38
+ total_bytes_compressed: int
39
+ retry_attempts: int
40
+ retried_requests: int
41
+ dlq_dropped_entries: int
42
+
43
+
44
+ class LatencyMetrics(TypedDict):
45
+ p50: float
46
+ p95: float
47
+ p99: float
48
+ avg: float
49
+
50
+
51
+ class CompressionMetrics(TypedDict):
52
+ avg_ratio: float
53
+ avg_time: float
54
+ total_saved_bytes: int
55
+
56
+
57
+ class LoggerMetrics(TypedDict, total=False):
58
+ logs_processed: int
59
+ logs_dropped: int
60
+ logs_sanitized: int
61
+ flush_count: int
62
+ error_count: int
63
+ buffer_size: int
64
+ avg_flush_time: float
65
+ last_flush_time: float
66
+ memory_usage: float
67
+ cpu_usage: float
68
+ circuit_breaker_trips: int
69
+ dropped_by_backpressure: int
70
+ dropped_by_dlq: int
71
+ latency: LatencyMetrics
72
+ compression: CompressionMetrics
73
+ throughput: float
74
+
75
+
76
+ class HealthStatus(TypedDict, total=False):
77
+ healthy: bool
78
+ last_successful_flush: float
79
+ consecutive_errors: int
80
+ buffer_utilization: float
81
+ uptime: float
82
+ memory_usage_mb: float
83
+ circuit_breaker_state: CircuitBreakerState
84
+ last_error: str
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RuntimeState:
89
+ initialized: bool