elven-logs-interceptor-python 0.1.3__tar.gz → 0.1.5__tar.gz
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.3 → elven_logs_interceptor_python-0.1.5}/PKG-INFO +1 -1
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/pyproject.toml +1 -1
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/__init__.py +5 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/application/config_service.py +4 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/application/log_service.py +3 -3
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/config.py +2 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/interceptors/runtime_interceptor.py +33 -7
- elven_logs_interceptor_python-0.1.5/src/logs_interceptor/infrastructure/internal_capture_guard.py +23 -0
- elven_logs_interceptor_python-0.1.5/src/logs_interceptor/infrastructure/log_noise_filter.py +56 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/transport/loki_json_transport.py +8 -2
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/transport/loki_protobuf_transport.py +8 -2
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/integrations/logging_handler.py +15 -1
- elven_logs_interceptor_python-0.1.5/src/logs_interceptor/integrations/loguru.py +85 -0
- elven_logs_interceptor_python-0.1.5/src/logs_interceptor/integrations/structlog.py +38 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/preload.py +13 -2
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/presentation/factory.py +8 -4
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/utils.py +20 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/integration/test_api.py +20 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_config_service.py +14 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_env_config.py +12 -0
- elven_logs_interceptor_python-0.1.5/tests/unit/test_integration_filters.py +86 -0
- elven_logs_interceptor_python-0.1.5/tests/unit/test_loguru_sink.py +137 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_loki_json_transport.py +21 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_protobuf_transport_safety.py +48 -0
- elven_logs_interceptor_python-0.1.5/tests/unit/test_runtime_interceptor.py +67 -0
- elven_logs_interceptor_python-0.1.3/src/logs_interceptor/integrations/loguru.py +0 -36
- elven_logs_interceptor_python-0.1.3/src/logs_interceptor/integrations/structlog.py +0 -21
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/.github/workflows/ci.yml +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/.github/workflows/publish.yml +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/.gitignore +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/ARCHITECTURE.md +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/Makefile +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/README.md +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/examples/basic_app.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/examples/fastapi_integration.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/examples/full_config_reference.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/examples/high_volume.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/examples/tracking_usage.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/scripts/publish.sh +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/application/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/domain/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/domain/entities.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/domain/interfaces.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/domain/value_objects.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/buffer/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/buffer/memory_buffer.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/circuit_breaker/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/circuit_breaker/circuit_breaker.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/compression/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/compression/base.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/compression/brotli_compressor.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/compression/factory.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/compression/gzip_compressor.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/compression/noop_compressor.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/context/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/context/context_provider.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/dlq/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/dlq/file_dlq.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/dlq/memory_dlq.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/filter/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/filter/log_filter.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/interceptors/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/memory/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/memory/memory_tracker.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/metrics/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/metrics/metrics_collector.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/transport/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/transport/resilient_transport.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/transport/transport_factory.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/workers/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/infrastructure/workers/worker_pool.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/integrations/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/integrations/celery.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/integrations/django.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/integrations/fastapi.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/integrations/flask.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/presentation/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/src/logs_interceptor/types.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/test-apps/elven-live-demo/.env.example +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/test-apps/elven-live-demo/README.md +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/test-apps/elven-live-demo/app.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/test-apps/elven-live-demo/run.sh +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/test-apps/elven-observability-smoke/.env.example +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/test-apps/elven-observability-smoke/README.md +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/test-apps/elven-observability-smoke/app.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/test-apps/elven-observability-smoke/run.sh +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_circuit_breaker_extra.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_core_components.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_log_filter_extra.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_log_service_unit.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_memory_buffer_extra.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_resilient_transport.py +0 -0
- {elven_logs_interceptor_python-0.1.3 → elven_logs_interceptor_python-0.1.5}/tests/unit/test_utils_extra.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "elven-logs-interceptor-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "Production-grade logs interceptor for Python with Loki transport, resilience, batching, and framework integrations."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -133,6 +133,11 @@ def _coerce_config(user_config: LogsInterceptorConfig | dict[str, Any] | None) -
|
|
|
133
133
|
max_message_length=_pick(filter_raw, "max_message_length", "maxMessageLength"),
|
|
134
134
|
sanitize=_pick(filter_raw, "sanitize", "sanitize"),
|
|
135
135
|
sensitive_patterns=_pick(filter_raw, "sensitive_patterns", "sensitivePatterns"),
|
|
136
|
+
exclude_logger_prefixes=_pick(
|
|
137
|
+
filter_raw,
|
|
138
|
+
"exclude_logger_prefixes",
|
|
139
|
+
"excludeLoggerPrefixes",
|
|
140
|
+
),
|
|
136
141
|
)
|
|
137
142
|
if filter_raw
|
|
138
143
|
else None
|
|
@@ -17,6 +17,7 @@ from ..config import (
|
|
|
17
17
|
ResolvedTransportConfig,
|
|
18
18
|
TransportConfig,
|
|
19
19
|
)
|
|
20
|
+
from ..infrastructure.log_noise_filter import normalize_excluded_logger_prefixes
|
|
20
21
|
from ..types import LogLevel
|
|
21
22
|
|
|
22
23
|
DEFAULT_LEVELS: list[LogLevel] = ["debug", "info", "warn", "error", "fatal"]
|
|
@@ -194,6 +195,9 @@ class ConfigService:
|
|
|
194
195
|
else source.max_message_length,
|
|
195
196
|
sanitize=True if source.sanitize is None else source.sanitize,
|
|
196
197
|
sensitive_patterns=source.sensitive_patterns or DEFAULT_SENSITIVE_PATTERNS,
|
|
198
|
+
exclude_logger_prefixes=list(
|
|
199
|
+
normalize_excluded_logger_prefixes(source.exclude_logger_prefixes)
|
|
200
|
+
),
|
|
197
201
|
)
|
|
198
202
|
|
|
199
203
|
@staticmethod
|
|
@@ -22,11 +22,11 @@ try:
|
|
|
22
22
|
except Exception: # pragma: no cover
|
|
23
23
|
pass
|
|
24
24
|
|
|
25
|
+
otel_trace: Any | None
|
|
25
26
|
try:
|
|
26
|
-
from opentelemetry import trace as
|
|
27
|
+
from opentelemetry import trace as otel_trace
|
|
27
28
|
except Exception: # pragma: no cover - optional dependency
|
|
28
|
-
|
|
29
|
-
otel_trace: Any = _otel_trace
|
|
29
|
+
otel_trace = None
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
@dataclass(slots=True)
|
|
@@ -45,6 +45,7 @@ class FilterConfig:
|
|
|
45
45
|
max_message_length: int | None = None
|
|
46
46
|
sanitize: bool | None = None
|
|
47
47
|
sensitive_patterns: list[str] | None = None
|
|
48
|
+
exclude_logger_prefixes: list[str] | None = None
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
@dataclass(slots=True)
|
|
@@ -149,6 +150,7 @@ class ResolvedFilterConfig:
|
|
|
149
150
|
max_message_length: int
|
|
150
151
|
sanitize: bool
|
|
151
152
|
sensitive_patterns: list[str]
|
|
153
|
+
exclude_logger_prefixes: list[str] = field(default_factory=list)
|
|
152
154
|
|
|
153
155
|
|
|
154
156
|
@dataclass(slots=True)
|
|
@@ -10,15 +10,27 @@ from typing import Any
|
|
|
10
10
|
from ...domain.interfaces import ILogger
|
|
11
11
|
from ...types import LogLevel
|
|
12
12
|
from ...utils import safe_stringify
|
|
13
|
+
from ..internal_capture_guard import is_internal_log_capture_suppressed
|
|
14
|
+
from ..log_noise_filter import normalize_excluded_logger_prefixes, should_drop_log_record
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
class _BridgeLoggingHandler(logging.Handler):
|
|
16
|
-
def __init__(self, logger: ILogger) -> None:
|
|
18
|
+
def __init__(self, logger: ILogger, exclude_prefixes: list[str] | None = None) -> None:
|
|
17
19
|
super().__init__()
|
|
18
20
|
self._logger = logger
|
|
21
|
+
self._exclude_prefixes = normalize_excluded_logger_prefixes(exclude_prefixes)
|
|
19
22
|
|
|
20
23
|
def emit(self, record: logging.LogRecord) -> None:
|
|
21
24
|
try:
|
|
25
|
+
if is_internal_log_capture_suppressed():
|
|
26
|
+
return
|
|
27
|
+
if should_drop_log_record(
|
|
28
|
+
logger_name=record.name,
|
|
29
|
+
module_name=record.module,
|
|
30
|
+
exclude_prefixes=self._exclude_prefixes,
|
|
31
|
+
):
|
|
32
|
+
return
|
|
33
|
+
|
|
22
34
|
level = self._map_level(record.levelno)
|
|
23
35
|
msg = record.getMessage()
|
|
24
36
|
context = {
|
|
@@ -48,20 +60,31 @@ class _BridgeLoggingHandler(logging.Handler):
|
|
|
48
60
|
|
|
49
61
|
|
|
50
62
|
class RuntimeInterceptor:
|
|
51
|
-
def __init__(
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
logger: ILogger,
|
|
66
|
+
preserve_original: bool = True,
|
|
67
|
+
exclude_prefixes: list[str] | None = None,
|
|
68
|
+
) -> None:
|
|
52
69
|
self._logger = logger
|
|
53
70
|
self._preserve_original = preserve_original
|
|
54
71
|
self._enabled = False
|
|
72
|
+
self._exclude_prefixes = list(normalize_excluded_logger_prefixes(exclude_prefixes))
|
|
55
73
|
|
|
56
74
|
self._original_print = builtins.print
|
|
57
75
|
self._original_excepthook = sys.excepthook
|
|
58
76
|
self._root_logger = logging.getLogger()
|
|
59
|
-
self.
|
|
77
|
+
self._original_root_level = self._root_logger.level
|
|
78
|
+
self._bridge_handler = _BridgeLoggingHandler(logger, self._exclude_prefixes)
|
|
60
79
|
self._original_handlers: list[logging.Handler] = []
|
|
61
80
|
|
|
62
81
|
def enable(self) -> None:
|
|
63
82
|
if self._enabled:
|
|
64
83
|
return
|
|
84
|
+
|
|
85
|
+
self._original_print = builtins.print
|
|
86
|
+
self._original_excepthook = sys.excepthook
|
|
87
|
+
self._original_root_level = self._root_logger.level
|
|
65
88
|
self._enabled = True
|
|
66
89
|
|
|
67
90
|
self._patch_print()
|
|
@@ -80,6 +103,8 @@ class RuntimeInterceptor:
|
|
|
80
103
|
except ValueError:
|
|
81
104
|
pass
|
|
82
105
|
|
|
106
|
+
self._root_logger.setLevel(self._original_root_level)
|
|
107
|
+
|
|
83
108
|
if not self._preserve_original:
|
|
84
109
|
self._root_logger.handlers = self._original_handlers
|
|
85
110
|
|
|
@@ -91,10 +116,11 @@ class RuntimeInterceptor:
|
|
|
91
116
|
def _patch_print(self) -> None:
|
|
92
117
|
def intercepted_print(*args: Any, **kwargs: Any) -> None:
|
|
93
118
|
try:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
if not is_internal_log_capture_suppressed():
|
|
120
|
+
message = " ".join(
|
|
121
|
+
[arg if isinstance(arg, str) else safe_stringify(arg) for arg in args]
|
|
122
|
+
)
|
|
123
|
+
self._logger.info(message, {"source": "print"})
|
|
98
124
|
except Exception:
|
|
99
125
|
pass
|
|
100
126
|
|
elven_logs_interceptor_python-0.1.5/src/logs_interceptor/infrastructure/internal_capture_guard.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
|
|
7
|
+
_SUPPRESSION_DEPTH: ContextVar[int] = ContextVar(
|
|
8
|
+
"logs_interceptor_internal_capture_suppression_depth",
|
|
9
|
+
default=0,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextmanager
|
|
14
|
+
def suppress_internal_log_capture() -> Iterator[None]:
|
|
15
|
+
token = _SUPPRESSION_DEPTH.set(_SUPPRESSION_DEPTH.get() + 1)
|
|
16
|
+
try:
|
|
17
|
+
yield
|
|
18
|
+
finally:
|
|
19
|
+
_SUPPRESSION_DEPTH.reset(token)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_internal_log_capture_suppressed() -> bool:
|
|
23
|
+
return _SUPPRESSION_DEPTH.get() > 0
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
DEFAULT_EXCLUDED_LOGGER_PREFIXES = (
|
|
7
|
+
"httpcore",
|
|
8
|
+
"httpx",
|
|
9
|
+
"urllib3",
|
|
10
|
+
"hpack",
|
|
11
|
+
"h2",
|
|
12
|
+
"h11",
|
|
13
|
+
"opentelemetry",
|
|
14
|
+
"logs_interceptor",
|
|
15
|
+
"elven_unified_observability",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def normalize_excluded_logger_prefixes(prefixes: Iterable[str] | None = None) -> tuple[str, ...]:
|
|
20
|
+
values = list(prefixes) if prefixes is not None else list(DEFAULT_EXCLUDED_LOGGER_PREFIXES)
|
|
21
|
+
normalized: list[str] = []
|
|
22
|
+
seen: set[str] = set()
|
|
23
|
+
for item in values:
|
|
24
|
+
value = str(item).strip().lower()
|
|
25
|
+
if not value or value in seen:
|
|
26
|
+
continue
|
|
27
|
+
seen.add(value)
|
|
28
|
+
normalized.append(value)
|
|
29
|
+
return tuple(normalized)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def should_drop_log_record(
|
|
33
|
+
*,
|
|
34
|
+
logger_name: str | None = None,
|
|
35
|
+
module_name: str | None = None,
|
|
36
|
+
extra: Mapping[str, Any] | None = None,
|
|
37
|
+
exclude_prefixes: Iterable[str] | None = None,
|
|
38
|
+
) -> bool:
|
|
39
|
+
prefixes = normalize_excluded_logger_prefixes(exclude_prefixes)
|
|
40
|
+
if not prefixes:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
normalized_logger_name = str(logger_name or "").strip().lower()
|
|
44
|
+
normalized_module_name = str(module_name or "").strip().lower()
|
|
45
|
+
normalized_extra_logger_name = ""
|
|
46
|
+
if isinstance(extra, Mapping):
|
|
47
|
+
normalized_extra_logger_name = str(extra.get("logger_name") or "").strip().lower()
|
|
48
|
+
|
|
49
|
+
for prefix in prefixes:
|
|
50
|
+
if (
|
|
51
|
+
normalized_logger_name.startswith(prefix)
|
|
52
|
+
or normalized_module_name.startswith(prefix)
|
|
53
|
+
or normalized_extra_logger_name.startswith(prefix)
|
|
54
|
+
):
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
@@ -12,7 +12,9 @@ import httpx
|
|
|
12
12
|
from ...config import ResolvedTransportConfig
|
|
13
13
|
from ...domain.entities import LogEntryEntity
|
|
14
14
|
from ...types import TransportHealth, TransportMetrics
|
|
15
|
+
from ...utils import get_distribution_version
|
|
15
16
|
from ..compression import CompressorConfig, CompressorFactory
|
|
17
|
+
from ..internal_capture_guard import suppress_internal_log_capture
|
|
16
18
|
|
|
17
19
|
try:
|
|
18
20
|
import orjson # type: ignore[import-not-found]
|
|
@@ -88,7 +90,10 @@ class LokiJsonTransport:
|
|
|
88
90
|
headers: dict[str, str] = {
|
|
89
91
|
"Content-Type": "application/json",
|
|
90
92
|
"X-Scope-OrgID": self._config.tenant_id,
|
|
91
|
-
"User-Agent":
|
|
93
|
+
"User-Agent": (
|
|
94
|
+
"elven-logs-interceptor-python/"
|
|
95
|
+
f"{get_distribution_version('elven-logs-interceptor-python')}"
|
|
96
|
+
),
|
|
92
97
|
**self._extra_headers,
|
|
93
98
|
}
|
|
94
99
|
if self._config.auth_token:
|
|
@@ -98,7 +103,8 @@ class LokiJsonTransport:
|
|
|
98
103
|
if encoding:
|
|
99
104
|
headers["Content-Encoding"] = encoding
|
|
100
105
|
|
|
101
|
-
|
|
106
|
+
with suppress_internal_log_capture():
|
|
107
|
+
response = self._request(headers, body)
|
|
102
108
|
|
|
103
109
|
if response.status_code >= 300:
|
|
104
110
|
raise RetryableTransportError(
|
|
@@ -11,6 +11,8 @@ import httpx
|
|
|
11
11
|
from ...config import ResolvedTransportConfig
|
|
12
12
|
from ...domain.entities import LogEntryEntity
|
|
13
13
|
from ...types import TransportHealth, TransportMetrics
|
|
14
|
+
from ...utils import get_distribution_version
|
|
15
|
+
from ..internal_capture_guard import suppress_internal_log_capture
|
|
14
16
|
|
|
15
17
|
try:
|
|
16
18
|
import snappy # type: ignore[import-not-found]
|
|
@@ -94,13 +96,17 @@ class LokiProtobufTransport:
|
|
|
94
96
|
"Content-Type": "application/x-protobuf",
|
|
95
97
|
"Content-Encoding": "snappy",
|
|
96
98
|
"X-Scope-OrgID": self._config.tenant_id,
|
|
97
|
-
"User-Agent":
|
|
99
|
+
"User-Agent": (
|
|
100
|
+
"elven-logs-interceptor-python/"
|
|
101
|
+
f"{get_distribution_version('elven-logs-interceptor-python')}"
|
|
102
|
+
),
|
|
98
103
|
**self._extra_headers,
|
|
99
104
|
}
|
|
100
105
|
if self._config.auth_token:
|
|
101
106
|
headers["Authorization"] = f"Bearer {self._config.auth_token}"
|
|
102
107
|
|
|
103
|
-
|
|
108
|
+
with suppress_internal_log_capture():
|
|
109
|
+
response = self._request(headers, compressed)
|
|
104
110
|
if response.status_code >= 300:
|
|
105
111
|
raise RetryableTransportError(
|
|
106
112
|
message=f"Loki responded with {response.status_code}: {response.text}",
|
|
@@ -5,16 +5,30 @@ import traceback as traceback_module
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from ..domain.interfaces import ILogger
|
|
8
|
+
from ..infrastructure.internal_capture_guard import is_internal_log_capture_suppressed
|
|
9
|
+
from ..infrastructure.log_noise_filter import (
|
|
10
|
+
normalize_excluded_logger_prefixes,
|
|
11
|
+
should_drop_log_record,
|
|
12
|
+
)
|
|
8
13
|
from ..types import LogLevel
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
class LoggingHandler(logging.Handler):
|
|
12
|
-
def __init__(self, logger: ILogger) -> None:
|
|
17
|
+
def __init__(self, logger: ILogger, exclude_prefixes: list[str] | None = None) -> None:
|
|
13
18
|
super().__init__()
|
|
14
19
|
self._logger = logger
|
|
20
|
+
self._exclude_prefixes = normalize_excluded_logger_prefixes(exclude_prefixes)
|
|
15
21
|
|
|
16
22
|
def emit(self, record: logging.LogRecord) -> None:
|
|
17
23
|
try:
|
|
24
|
+
if is_internal_log_capture_suppressed():
|
|
25
|
+
return
|
|
26
|
+
if should_drop_log_record(
|
|
27
|
+
logger_name=record.name,
|
|
28
|
+
module_name=record.module,
|
|
29
|
+
exclude_prefixes=self._exclude_prefixes,
|
|
30
|
+
):
|
|
31
|
+
return
|
|
18
32
|
level = self._map_level(record.levelno)
|
|
19
33
|
message = record.getMessage()
|
|
20
34
|
context: dict[str, Any] = {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..domain.interfaces import ILogger
|
|
7
|
+
from ..infrastructure.internal_capture_guard import is_internal_log_capture_suppressed
|
|
8
|
+
from ..infrastructure.log_noise_filter import (
|
|
9
|
+
normalize_excluded_logger_prefixes,
|
|
10
|
+
should_drop_log_record,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoguruSink:
|
|
15
|
+
def __init__(self, logger: ILogger, exclude_prefixes: list[str] | None = None) -> None:
|
|
16
|
+
self.logger = logger
|
|
17
|
+
self._exclude_prefixes = normalize_excluded_logger_prefixes(exclude_prefixes)
|
|
18
|
+
|
|
19
|
+
def __call__(self, message: Any) -> None:
|
|
20
|
+
try:
|
|
21
|
+
if is_internal_log_capture_suppressed():
|
|
22
|
+
return
|
|
23
|
+
record = getattr(message, "record", None)
|
|
24
|
+
if not isinstance(record, Mapping):
|
|
25
|
+
self.logger.info(str(message), {"source": "loguru"})
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
if self._should_ignore(record):
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
level = self._resolve_level(record)
|
|
32
|
+
extra = record.get("extra")
|
|
33
|
+
normalized_extra = dict(extra) if isinstance(extra, Mapping) else extra
|
|
34
|
+
|
|
35
|
+
self.logger.log(
|
|
36
|
+
level, # type: ignore[arg-type]
|
|
37
|
+
str(record.get("message", "")),
|
|
38
|
+
{
|
|
39
|
+
"source": "loguru",
|
|
40
|
+
"logger_name": record.get("name"),
|
|
41
|
+
"module": record.get("module"),
|
|
42
|
+
"function": record.get("function"),
|
|
43
|
+
"line": record.get("line"),
|
|
44
|
+
"extra": normalized_extra,
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
except Exception:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _resolve_level(record: Mapping[str, Any]) -> str:
|
|
52
|
+
raw_level = record.get("level")
|
|
53
|
+
if isinstance(raw_level, Mapping):
|
|
54
|
+
level = str(raw_level.get("name", "INFO")).lower()
|
|
55
|
+
level_number = raw_level.get("no")
|
|
56
|
+
else:
|
|
57
|
+
level = str(getattr(raw_level, "name", "INFO")).lower()
|
|
58
|
+
level_number = getattr(raw_level, "no", None)
|
|
59
|
+
|
|
60
|
+
if level == "warning":
|
|
61
|
+
return "warn"
|
|
62
|
+
if level == "critical":
|
|
63
|
+
return "fatal"
|
|
64
|
+
if level not in {"debug", "info", "warn", "error", "fatal"}:
|
|
65
|
+
if not level and isinstance(level_number, (int, float)):
|
|
66
|
+
if level_number >= 50:
|
|
67
|
+
return "fatal"
|
|
68
|
+
if level_number >= 40:
|
|
69
|
+
return "error"
|
|
70
|
+
if level_number >= 30:
|
|
71
|
+
return "warn"
|
|
72
|
+
if level_number >= 20:
|
|
73
|
+
return "info"
|
|
74
|
+
return "debug"
|
|
75
|
+
return "info"
|
|
76
|
+
return level
|
|
77
|
+
|
|
78
|
+
def _should_ignore(self, record: Mapping[str, Any]) -> bool:
|
|
79
|
+
extra = record.get("extra")
|
|
80
|
+
return should_drop_log_record(
|
|
81
|
+
logger_name=str(record.get("name") or ""),
|
|
82
|
+
module_name=str(record.get("module") or ""),
|
|
83
|
+
extra=extra if isinstance(extra, Mapping) else None,
|
|
84
|
+
exclude_prefixes=self._exclude_prefixes,
|
|
85
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..domain.interfaces import ILogger
|
|
7
|
+
from ..infrastructure.internal_capture_guard import is_internal_log_capture_suppressed
|
|
8
|
+
from ..infrastructure.log_noise_filter import (
|
|
9
|
+
normalize_excluded_logger_prefixes,
|
|
10
|
+
should_drop_log_record,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StructlogProcessor:
|
|
15
|
+
def __init__(self, logger: ILogger, exclude_prefixes: list[str] | None = None) -> None:
|
|
16
|
+
self.logger = logger
|
|
17
|
+
self._exclude_prefixes = normalize_excluded_logger_prefixes(exclude_prefixes)
|
|
18
|
+
|
|
19
|
+
def __call__(self, _logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]:
|
|
20
|
+
if is_internal_log_capture_suppressed():
|
|
21
|
+
return event_dict
|
|
22
|
+
if should_drop_log_record(
|
|
23
|
+
logger_name=event_dict.get("logger_name"),
|
|
24
|
+
module_name=event_dict.get("module"),
|
|
25
|
+
extra=event_dict.get("extra") if isinstance(event_dict.get("extra"), Mapping) else None,
|
|
26
|
+
exclude_prefixes=self._exclude_prefixes,
|
|
27
|
+
):
|
|
28
|
+
return event_dict
|
|
29
|
+
|
|
30
|
+
message = str(event_dict.get("event", method_name))
|
|
31
|
+
level = method_name.lower()
|
|
32
|
+
if level not in {"debug", "info", "warn", "error", "fatal"}:
|
|
33
|
+
level = "info"
|
|
34
|
+
|
|
35
|
+
context = dict(event_dict)
|
|
36
|
+
context.pop("event", None)
|
|
37
|
+
self.logger.log(level, message, {"source": "structlog", **context}) # type: ignore[arg-type]
|
|
38
|
+
return event_dict
|
|
@@ -8,14 +8,25 @@ import sys
|
|
|
8
8
|
_LOGS_INTERCEPTOR_PRELOADED = False
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def _write(stream: object, prefix: str, *args: object) -> None:
|
|
12
|
+
message = " ".join(str(arg) for arg in args)
|
|
13
|
+
text = f"{prefix} {message}\n"
|
|
14
|
+
writer = getattr(stream, "write", None)
|
|
15
|
+
flusher = getattr(stream, "flush", None)
|
|
16
|
+
if callable(writer):
|
|
17
|
+
writer(text)
|
|
18
|
+
if callable(flusher):
|
|
19
|
+
flusher()
|
|
20
|
+
|
|
21
|
+
|
|
11
22
|
def _debug(*args: object) -> None:
|
|
12
23
|
if os.getenv("LOGS_DEBUG") == "true" and os.getenv("LOGS_SILENT_ERRORS") != "true":
|
|
13
|
-
|
|
24
|
+
_write(sys.stdout, "[logs-interceptor:preload]", *args)
|
|
14
25
|
|
|
15
26
|
|
|
16
27
|
def _error(*args: object) -> None:
|
|
17
28
|
if os.getenv("LOGS_SILENT_ERRORS") != "true":
|
|
18
|
-
|
|
29
|
+
_write(sys.stderr, "[logs-interceptor:preload]", *args)
|
|
19
30
|
|
|
20
31
|
|
|
21
32
|
def _install() -> None:
|
|
@@ -15,11 +15,11 @@ from ..infrastructure.filter import LogFilter
|
|
|
15
15
|
from ..infrastructure.interceptors import RuntimeInterceptor
|
|
16
16
|
from ..infrastructure.transport import TransportFactory
|
|
17
17
|
|
|
18
|
+
otel_trace: Any | None
|
|
18
19
|
try:
|
|
19
|
-
from opentelemetry import trace as
|
|
20
|
+
from opentelemetry import trace as otel_trace
|
|
20
21
|
except Exception: # pragma: no cover - optional dependency
|
|
21
|
-
|
|
22
|
-
otel_trace: Any = _otel_trace
|
|
22
|
+
otel_trace = None
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
@dataclass(slots=True)
|
|
@@ -81,7 +81,11 @@ class LogsInterceptorFactory:
|
|
|
81
81
|
|
|
82
82
|
runtime_interceptor = None
|
|
83
83
|
if config.intercept_console:
|
|
84
|
-
runtime_interceptor = RuntimeInterceptor(
|
|
84
|
+
runtime_interceptor = RuntimeInterceptor(
|
|
85
|
+
logger,
|
|
86
|
+
config.preserve_original_console,
|
|
87
|
+
exclude_prefixes=config.filter.exclude_logger_prefixes,
|
|
88
|
+
)
|
|
85
89
|
runtime_interceptor.enable()
|
|
86
90
|
|
|
87
91
|
original_destroy = logger.destroy
|
|
@@ -10,6 +10,7 @@ import sys
|
|
|
10
10
|
from collections.abc import Iterable
|
|
11
11
|
from dataclasses import asdict, fields, is_dataclass
|
|
12
12
|
from datetime import datetime
|
|
13
|
+
from functools import cache
|
|
13
14
|
from typing import Any, cast
|
|
14
15
|
|
|
15
16
|
from .config import (
|
|
@@ -80,6 +81,19 @@ def is_silent_errors_enabled() -> bool:
|
|
|
80
81
|
return parse_bool(os.getenv("LOGS_SILENT_ERRORS"), False)
|
|
81
82
|
|
|
82
83
|
|
|
84
|
+
@cache
|
|
85
|
+
def get_distribution_version(distribution_name: str) -> str:
|
|
86
|
+
try:
|
|
87
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
88
|
+
except ImportError: # pragma: no cover - Python 3.10+ should not hit this
|
|
89
|
+
return "unknown"
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
return version(distribution_name)
|
|
93
|
+
except PackageNotFoundError:
|
|
94
|
+
return "unknown"
|
|
95
|
+
|
|
96
|
+
|
|
83
97
|
def _internal_log(level: str, message: str, context: Any | None = None) -> None:
|
|
84
98
|
if level == "debug" and not is_debug_enabled():
|
|
85
99
|
return
|
|
@@ -396,6 +410,12 @@ def load_config_from_env() -> LogsInterceptorConfig:
|
|
|
396
410
|
max_message_length=parse_int_range(
|
|
397
411
|
env.get("LOGS_FILTER_MAX_MESSAGE_LENGTH"), 8192, 64, 1_000_000
|
|
398
412
|
),
|
|
413
|
+
exclude_logger_prefixes=[
|
|
414
|
+
item.strip()
|
|
415
|
+
for item in (env.get("LOGS_FILTER_EXCLUDE_LOGGER_PREFIXES") or "").split(",")
|
|
416
|
+
if item.strip()
|
|
417
|
+
]
|
|
418
|
+
or None,
|
|
399
419
|
),
|
|
400
420
|
circuit_breaker=CircuitBreakerConfig(
|
|
401
421
|
enabled=parse_bool(env.get("LOGS_CIRCUIT_BREAKER_ENABLED"), True),
|
|
@@ -100,3 +100,23 @@ def test_flush_propagates_transport_errors() -> None:
|
|
|
100
100
|
|
|
101
101
|
with pytest.raises(httpx.ConnectError):
|
|
102
102
|
instance.flush()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_runtime_uses_configured_excluded_logger_prefixes() -> None:
|
|
106
|
+
instance = init(
|
|
107
|
+
{
|
|
108
|
+
**_valid_config(),
|
|
109
|
+
"interceptConsole": True,
|
|
110
|
+
"filter": {"excludeLoggerPrefixes": ["botocore"]},
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
assert instance is get_logger()
|
|
114
|
+
|
|
115
|
+
import logging
|
|
116
|
+
|
|
117
|
+
logging.getLogger("botocore.endpoint").warning("should be ignored")
|
|
118
|
+
logging.getLogger("service.app").warning("should be captured")
|
|
119
|
+
|
|
120
|
+
metrics = instance.get_metrics()
|
|
121
|
+
assert metrics["logs_processed"] >= 1
|
|
122
|
+
assert metrics["logs_processed"] < 3
|
|
@@ -49,6 +49,8 @@ def test_resolve_applies_defaults() -> None:
|
|
|
49
49
|
assert resolved.transport.max_retries == 3
|
|
50
50
|
assert resolved.buffer.max_size == 100
|
|
51
51
|
assert resolved.filter.sampling_rate == 1.0
|
|
52
|
+
assert "httpx" in resolved.filter.exclude_logger_prefixes
|
|
53
|
+
assert "logs_interceptor" in resolved.filter.exclude_logger_prefixes
|
|
52
54
|
assert resolved.circuit_breaker.failure_threshold == 50
|
|
53
55
|
assert resolved.performance.max_concurrent_flushes == 3
|
|
54
56
|
|
|
@@ -112,3 +114,15 @@ def test_resolve_transport_compression_variants() -> None:
|
|
|
112
114
|
)
|
|
113
115
|
)
|
|
114
116
|
assert snappy_cfg.transport.compression == "snappy"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_resolve_filter_normalizes_custom_excluded_prefixes() -> None:
|
|
120
|
+
resolved = ConfigService.resolve(
|
|
121
|
+
LogsInterceptorConfig(
|
|
122
|
+
transport=TransportConfig(url="https://loki.example.com/loki/api/v1/push", tenant_id="tenant"),
|
|
123
|
+
app_name="app",
|
|
124
|
+
filter=FilterConfig(exclude_logger_prefixes=["HTTPX", " httpx ", "custom_stack"]),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert resolved.filter.exclude_logger_prefixes == ["httpx", "custom_stack"]
|
|
@@ -45,3 +45,15 @@ def test_load_labels_from_prefix(monkeypatch) -> None:
|
|
|
45
45
|
config = load_config_from_env()
|
|
46
46
|
|
|
47
47
|
assert config.labels == {"service": "busca-prd", "environment": "prd"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_load_excluded_logger_prefixes_from_env(monkeypatch) -> None:
|
|
51
|
+
monkeypatch.setenv("LOGS_URL", "https://loki.example.com/loki/api/v1/push")
|
|
52
|
+
monkeypatch.setenv("LOGS_TENANT", "tenant-a")
|
|
53
|
+
monkeypatch.setenv("LOGS_APP_NAME", "app-a")
|
|
54
|
+
monkeypatch.setenv("LOGS_FILTER_EXCLUDE_LOGGER_PREFIXES", "httpx, httpcore ,custom_stack")
|
|
55
|
+
|
|
56
|
+
config = load_config_from_env()
|
|
57
|
+
|
|
58
|
+
assert config.filter is not None
|
|
59
|
+
assert config.filter.exclude_logger_prefixes == ["httpx", "httpcore", "custom_stack"]
|