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,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import builtins
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
import traceback as traceback_module
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ...domain.interfaces import ILogger
|
|
11
|
+
from ...types import LogLevel
|
|
12
|
+
from ...utils import safe_stringify
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _BridgeLoggingHandler(logging.Handler):
|
|
16
|
+
def __init__(self, logger: ILogger) -> None:
|
|
17
|
+
super().__init__()
|
|
18
|
+
self._logger = logger
|
|
19
|
+
|
|
20
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
21
|
+
try:
|
|
22
|
+
level = self._map_level(record.levelno)
|
|
23
|
+
msg = record.getMessage()
|
|
24
|
+
context = {
|
|
25
|
+
"source": "logging",
|
|
26
|
+
"logger_name": record.name,
|
|
27
|
+
"module": record.module,
|
|
28
|
+
"function": record.funcName,
|
|
29
|
+
"line": record.lineno,
|
|
30
|
+
}
|
|
31
|
+
if record.exc_info:
|
|
32
|
+
context["exc_info"] = "".join(traceback_module.format_exception(*record.exc_info))
|
|
33
|
+
self._logger.log(level, msg, context)
|
|
34
|
+
except Exception:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _map_level(level_no: int) -> LogLevel:
|
|
39
|
+
if level_no >= logging.CRITICAL:
|
|
40
|
+
return "fatal"
|
|
41
|
+
if level_no >= logging.ERROR:
|
|
42
|
+
return "error"
|
|
43
|
+
if level_no >= logging.WARNING:
|
|
44
|
+
return "warn"
|
|
45
|
+
if level_no >= logging.INFO:
|
|
46
|
+
return "info"
|
|
47
|
+
return "debug"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RuntimeInterceptor:
|
|
51
|
+
def __init__(self, logger: ILogger, preserve_original: bool = True) -> None:
|
|
52
|
+
self._logger = logger
|
|
53
|
+
self._preserve_original = preserve_original
|
|
54
|
+
self._enabled = False
|
|
55
|
+
|
|
56
|
+
self._original_print = builtins.print
|
|
57
|
+
self._original_excepthook = sys.excepthook
|
|
58
|
+
self._root_logger = logging.getLogger()
|
|
59
|
+
self._bridge_handler = _BridgeLoggingHandler(logger)
|
|
60
|
+
self._original_handlers: list[logging.Handler] = []
|
|
61
|
+
|
|
62
|
+
def enable(self) -> None:
|
|
63
|
+
if self._enabled:
|
|
64
|
+
return
|
|
65
|
+
self._enabled = True
|
|
66
|
+
|
|
67
|
+
self._patch_print()
|
|
68
|
+
self._patch_excepthook()
|
|
69
|
+
self._patch_logging()
|
|
70
|
+
|
|
71
|
+
def restore(self) -> None:
|
|
72
|
+
if not self._enabled:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
builtins.print = self._original_print
|
|
76
|
+
sys.excepthook = self._original_excepthook
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
self._root_logger.removeHandler(self._bridge_handler)
|
|
80
|
+
except ValueError:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
if not self._preserve_original:
|
|
84
|
+
self._root_logger.handlers = self._original_handlers
|
|
85
|
+
|
|
86
|
+
self._enabled = False
|
|
87
|
+
|
|
88
|
+
def is_enabled(self) -> bool:
|
|
89
|
+
return self._enabled
|
|
90
|
+
|
|
91
|
+
def _patch_print(self) -> None:
|
|
92
|
+
def intercepted_print(*args: Any, **kwargs: Any) -> None:
|
|
93
|
+
try:
|
|
94
|
+
message = " ".join(
|
|
95
|
+
[arg if isinstance(arg, str) else safe_stringify(arg) for arg in args]
|
|
96
|
+
)
|
|
97
|
+
self._logger.info(message, {"source": "print"})
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
if self._preserve_original:
|
|
102
|
+
self._original_print(*args, **kwargs)
|
|
103
|
+
|
|
104
|
+
builtins.print = intercepted_print
|
|
105
|
+
|
|
106
|
+
def _patch_excepthook(self) -> None:
|
|
107
|
+
def intercepted_excepthook(
|
|
108
|
+
exc_type: type[BaseException],
|
|
109
|
+
exc_value: BaseException,
|
|
110
|
+
traceback: TracebackType | None,
|
|
111
|
+
) -> None:
|
|
112
|
+
try:
|
|
113
|
+
self._logger.fatal(
|
|
114
|
+
f"Uncaught exception: {exc_value}",
|
|
115
|
+
{
|
|
116
|
+
"source": "sys.excepthook",
|
|
117
|
+
"exception_type": exc_type.__name__,
|
|
118
|
+
"traceback": "".join(traceback_module.format_tb(traceback))
|
|
119
|
+
if traceback
|
|
120
|
+
else None,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
self._logger.flush()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
if self._preserve_original and self._original_excepthook is not None:
|
|
128
|
+
self._original_excepthook(exc_type, exc_value, traceback)
|
|
129
|
+
|
|
130
|
+
sys.excepthook = intercepted_excepthook
|
|
131
|
+
|
|
132
|
+
def _patch_logging(self) -> None:
|
|
133
|
+
if not self._preserve_original:
|
|
134
|
+
self._original_handlers = list(self._root_logger.handlers)
|
|
135
|
+
self._root_logger.handlers = []
|
|
136
|
+
|
|
137
|
+
self._bridge_handler.setLevel(logging.DEBUG)
|
|
138
|
+
self._root_logger.addHandler(self._bridge_handler)
|
|
139
|
+
self._root_logger.setLevel(min(self._root_logger.level, logging.DEBUG) or logging.DEBUG)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from ...domain.entities import LogEntryEntity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class MemoryStats:
|
|
10
|
+
total_bytes: int
|
|
11
|
+
total_mb: float
|
|
12
|
+
entry_count: int
|
|
13
|
+
avg_entry_size: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryTracker:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._total_size = 0
|
|
19
|
+
self._entry_sizes: dict[int, int] = {}
|
|
20
|
+
self._entry_count = 0
|
|
21
|
+
|
|
22
|
+
def add_entry(self, entry: LogEntryEntity) -> None:
|
|
23
|
+
size = self._estimate_size(entry)
|
|
24
|
+
self._entry_sizes[id(entry)] = size
|
|
25
|
+
self._total_size += size
|
|
26
|
+
self._entry_count += 1
|
|
27
|
+
|
|
28
|
+
def remove_entry(self, entry: LogEntryEntity) -> None:
|
|
29
|
+
key = id(entry)
|
|
30
|
+
size = self._entry_sizes.pop(key, None)
|
|
31
|
+
if size is None:
|
|
32
|
+
return
|
|
33
|
+
self._total_size -= size
|
|
34
|
+
self._entry_count -= 1
|
|
35
|
+
|
|
36
|
+
def remove_entries(self, entries: list[LogEntryEntity]) -> None:
|
|
37
|
+
for entry in entries:
|
|
38
|
+
self.remove_entry(entry)
|
|
39
|
+
|
|
40
|
+
def get_total_size(self) -> int:
|
|
41
|
+
return self._total_size
|
|
42
|
+
|
|
43
|
+
def get_total_size_mb(self) -> float:
|
|
44
|
+
return self._total_size / 1024 / 1024
|
|
45
|
+
|
|
46
|
+
def get_entry_count(self) -> int:
|
|
47
|
+
return self._entry_count
|
|
48
|
+
|
|
49
|
+
def get_avg_entry_size(self) -> float:
|
|
50
|
+
if self._entry_count <= 0:
|
|
51
|
+
return 0.0
|
|
52
|
+
return self._total_size / self._entry_count
|
|
53
|
+
|
|
54
|
+
def get_stats(self) -> MemoryStats:
|
|
55
|
+
return MemoryStats(
|
|
56
|
+
total_bytes=self._total_size,
|
|
57
|
+
total_mb=self.get_total_size_mb(),
|
|
58
|
+
entry_count=self._entry_count,
|
|
59
|
+
avg_entry_size=self.get_avg_entry_size(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def reset(self) -> None:
|
|
63
|
+
self._total_size = 0
|
|
64
|
+
self._entry_sizes.clear()
|
|
65
|
+
self._entry_count = 0
|
|
66
|
+
|
|
67
|
+
def _estimate_size(self, entry: LogEntryEntity) -> int:
|
|
68
|
+
size = 0
|
|
69
|
+
size += len(entry.id) * 2
|
|
70
|
+
size += len(entry.timestamp) * 2
|
|
71
|
+
size += len(entry.level) * 2
|
|
72
|
+
size += len(entry.message) * 2
|
|
73
|
+
|
|
74
|
+
if entry.trace_id:
|
|
75
|
+
size += len(entry.trace_id) * 2
|
|
76
|
+
if entry.span_id:
|
|
77
|
+
size += len(entry.span_id) * 2
|
|
78
|
+
if entry.request_id:
|
|
79
|
+
size += len(entry.request_id) * 2
|
|
80
|
+
|
|
81
|
+
if entry.context:
|
|
82
|
+
keys = list(entry.context.keys())
|
|
83
|
+
size += len(keys) * 20
|
|
84
|
+
size += len(keys) * 50
|
|
85
|
+
|
|
86
|
+
if entry.labels:
|
|
87
|
+
for key, value in entry.labels.items():
|
|
88
|
+
size += len(key) * 2
|
|
89
|
+
size += len(value) * 2
|
|
90
|
+
|
|
91
|
+
if entry.metadata:
|
|
92
|
+
size += 50
|
|
93
|
+
|
|
94
|
+
size += 200
|
|
95
|
+
return size
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from statistics import mean
|
|
4
|
+
from time import time
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MetricsCollector:
|
|
8
|
+
def __init__(self, max_samples: int = 10_000) -> None:
|
|
9
|
+
self._latencies: list[float] = []
|
|
10
|
+
self._compression_ratios: list[float] = []
|
|
11
|
+
self._compression_times: list[float] = []
|
|
12
|
+
self._operation_timestamps: list[float] = []
|
|
13
|
+
self._total_original_bytes = 0
|
|
14
|
+
self._total_compressed_bytes = 0
|
|
15
|
+
self._max_samples = max_samples
|
|
16
|
+
|
|
17
|
+
def record_latency(self, ms: float) -> None:
|
|
18
|
+
self._latencies.append(ms)
|
|
19
|
+
self._operation_timestamps.append(time())
|
|
20
|
+
|
|
21
|
+
if len(self._latencies) > self._max_samples:
|
|
22
|
+
self._latencies.pop(0)
|
|
23
|
+
if len(self._operation_timestamps) > self._max_samples:
|
|
24
|
+
self._operation_timestamps.pop(0)
|
|
25
|
+
|
|
26
|
+
def record_compression(self, original_size: int, compressed_size: int, time_ms: float) -> None:
|
|
27
|
+
self._total_original_bytes += original_size
|
|
28
|
+
self._total_compressed_bytes += compressed_size
|
|
29
|
+
|
|
30
|
+
if original_size > 0:
|
|
31
|
+
ratio = (1 - compressed_size / original_size) * 100
|
|
32
|
+
self._compression_ratios.append(ratio)
|
|
33
|
+
if len(self._compression_ratios) > self._max_samples:
|
|
34
|
+
self._compression_ratios.pop(0)
|
|
35
|
+
|
|
36
|
+
self._compression_times.append(time_ms)
|
|
37
|
+
if len(self._compression_times) > self._max_samples:
|
|
38
|
+
self._compression_times.pop(0)
|
|
39
|
+
|
|
40
|
+
def get_latency_metrics(self) -> dict[str, float]:
|
|
41
|
+
if not self._latencies:
|
|
42
|
+
return {
|
|
43
|
+
"p50": 0.0,
|
|
44
|
+
"p95": 0.0,
|
|
45
|
+
"p99": 0.0,
|
|
46
|
+
"p999": 0.0,
|
|
47
|
+
"min": 0.0,
|
|
48
|
+
"max": 0.0,
|
|
49
|
+
"avg": 0.0,
|
|
50
|
+
"count": 0.0,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
sorted_values = sorted(self._latencies)
|
|
54
|
+
count = len(sorted_values)
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
"p50": self._get_percentile(sorted_values, 50),
|
|
58
|
+
"p95": self._get_percentile(sorted_values, 95),
|
|
59
|
+
"p99": self._get_percentile(sorted_values, 99),
|
|
60
|
+
"p999": self._get_percentile(sorted_values, 99.9),
|
|
61
|
+
"min": sorted_values[0],
|
|
62
|
+
"max": sorted_values[-1],
|
|
63
|
+
"avg": mean(sorted_values),
|
|
64
|
+
"count": float(count),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def get_compression_metrics(self) -> dict[str, float | int]:
|
|
68
|
+
avg_ratio = mean(self._compression_ratios) if self._compression_ratios else 0.0
|
|
69
|
+
avg_time = mean(self._compression_times) if self._compression_times else 0.0
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"avg_ratio": avg_ratio,
|
|
73
|
+
"avg_time": avg_time,
|
|
74
|
+
"total_original_bytes": self._total_original_bytes,
|
|
75
|
+
"total_compressed_bytes": self._total_compressed_bytes,
|
|
76
|
+
"total_saved_bytes": self._total_original_bytes - self._total_compressed_bytes,
|
|
77
|
+
"count": len(self._compression_ratios),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def get_throughput(self, window_seconds: int = 60) -> float:
|
|
81
|
+
if window_seconds <= 0:
|
|
82
|
+
return 0.0
|
|
83
|
+
if not self._operation_timestamps:
|
|
84
|
+
return 0.0
|
|
85
|
+
|
|
86
|
+
now = time()
|
|
87
|
+
window_start = now - window_seconds
|
|
88
|
+
in_window = len([ts for ts in self._operation_timestamps if ts >= window_start])
|
|
89
|
+
return in_window / window_seconds
|
|
90
|
+
|
|
91
|
+
def reset(self) -> None:
|
|
92
|
+
self._latencies.clear()
|
|
93
|
+
self._compression_ratios.clear()
|
|
94
|
+
self._compression_times.clear()
|
|
95
|
+
self._operation_timestamps.clear()
|
|
96
|
+
self._total_original_bytes = 0
|
|
97
|
+
self._total_compressed_bytes = 0
|
|
98
|
+
|
|
99
|
+
def _get_percentile(self, sorted_values: list[float], percentile: float) -> float:
|
|
100
|
+
if not sorted_values:
|
|
101
|
+
return 0.0
|
|
102
|
+
index = int((percentile / 100) * len(sorted_values))
|
|
103
|
+
bounded = min(max(index, 1), len(sorted_values)) - 1
|
|
104
|
+
return sorted_values[bounded]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .loki_json_transport import LokiJsonTransport
|
|
2
|
+
from .loki_protobuf_transport import LokiProtobufTransport
|
|
3
|
+
from .resilient_transport import ResilientTransport, ResilientTransportConfig
|
|
4
|
+
from .transport_factory import TransportFactory
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"LokiJsonTransport",
|
|
8
|
+
"LokiProtobufTransport",
|
|
9
|
+
"ResilientTransport",
|
|
10
|
+
"ResilientTransportConfig",
|
|
11
|
+
"TransportFactory",
|
|
12
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ...config import ResolvedTransportConfig
|
|
13
|
+
from ...domain.entities import LogEntryEntity
|
|
14
|
+
from ...types import TransportHealth, TransportMetrics
|
|
15
|
+
from ..compression import CompressorConfig, CompressorFactory
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import orjson # type: ignore[import-not-found]
|
|
19
|
+
except Exception: # pragma: no cover - optional dependency
|
|
20
|
+
orjson = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class RetryableTransportError(Exception):
|
|
25
|
+
message: str
|
|
26
|
+
status_code: int | None = None
|
|
27
|
+
retryable: bool = False
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return self.message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LokiJsonTransport:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
config: ResolvedTransportConfig,
|
|
37
|
+
extra_headers: dict[str, str] | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._config = config
|
|
40
|
+
self._extra_headers = extra_headers or {}
|
|
41
|
+
self._timeout = config.timeout / 1000
|
|
42
|
+
self._compression_threshold = config.compression_threshold
|
|
43
|
+
self._compressor = CompressorFactory.create(
|
|
44
|
+
config.compression,
|
|
45
|
+
CompressorConfig(level=config.compression_level, threshold=config.compression_threshold),
|
|
46
|
+
)
|
|
47
|
+
limits = httpx.Limits(max_keepalive_connections=config.max_sockets, max_connections=config.max_sockets)
|
|
48
|
+
self._client: httpx.Client | None = None
|
|
49
|
+
if config.enable_connection_pooling:
|
|
50
|
+
self._client = httpx.Client(timeout=self._timeout, limits=limits)
|
|
51
|
+
|
|
52
|
+
self._health: TransportHealth = {
|
|
53
|
+
"healthy": True,
|
|
54
|
+
"consecutive_failures": 0,
|
|
55
|
+
}
|
|
56
|
+
self._metrics: TransportMetrics = {
|
|
57
|
+
"total_sends": 0,
|
|
58
|
+
"successful_sends": 0,
|
|
59
|
+
"failed_sends": 0,
|
|
60
|
+
"avg_latency": 0.0,
|
|
61
|
+
"avg_compression_time": 0.0,
|
|
62
|
+
"avg_compression_ratio": 0.0,
|
|
63
|
+
"total_bytes_sent": 0,
|
|
64
|
+
"total_bytes_compressed": 0,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def send(self, entries: list[LogEntryEntity]) -> None:
|
|
68
|
+
if not entries:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
start = time.perf_counter()
|
|
72
|
+
self._metrics["total_sends"] = self._metrics.get("total_sends", 0) + 1
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
payload = self._format_for_loki(entries)
|
|
76
|
+
raw_bytes = self._dumps(payload)
|
|
77
|
+
raw_size = len(raw_bytes)
|
|
78
|
+
|
|
79
|
+
body = raw_bytes
|
|
80
|
+
compression_time = 0.0
|
|
81
|
+
was_compressed = False
|
|
82
|
+
if self._compressor.get_name() != "none" and raw_size >= self._compression_threshold:
|
|
83
|
+
compression_start = time.perf_counter()
|
|
84
|
+
body = self._compressor.compress(raw_bytes)
|
|
85
|
+
compression_time = (time.perf_counter() - compression_start) * 1000
|
|
86
|
+
was_compressed = True
|
|
87
|
+
|
|
88
|
+
headers: dict[str, str] = {
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"X-Scope-OrgID": self._config.tenant_id,
|
|
91
|
+
"User-Agent": "elven-logs-interceptor-python/0.1.2",
|
|
92
|
+
**self._extra_headers,
|
|
93
|
+
}
|
|
94
|
+
if self._config.auth_token:
|
|
95
|
+
headers["Authorization"] = f"Bearer {self._config.auth_token}"
|
|
96
|
+
if was_compressed:
|
|
97
|
+
encoding = self._compressor.get_content_encoding()
|
|
98
|
+
if encoding:
|
|
99
|
+
headers["Content-Encoding"] = encoding
|
|
100
|
+
|
|
101
|
+
response = self._request(headers, body)
|
|
102
|
+
|
|
103
|
+
if response.status_code >= 300:
|
|
104
|
+
raise RetryableTransportError(
|
|
105
|
+
message=f"Loki responded with {response.status_code}: {response.text}",
|
|
106
|
+
status_code=response.status_code,
|
|
107
|
+
retryable=response.status_code == 429 or response.status_code >= 500,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
latency = (time.perf_counter() - start) * 1000
|
|
111
|
+
self._record_success(latency)
|
|
112
|
+
self._metrics["total_bytes_sent"] = self._metrics.get("total_bytes_sent", 0) + len(body)
|
|
113
|
+
self._metrics["total_bytes_compressed"] = self._metrics.get("total_bytes_compressed", 0) + raw_size
|
|
114
|
+
|
|
115
|
+
if was_compressed:
|
|
116
|
+
self._update_compression_metrics(compression_time, raw_size, len(body))
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
self._record_failure(exc)
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
def _request(self, headers: dict[str, str], body: bytes) -> httpx.Response:
|
|
122
|
+
if self._client is not None:
|
|
123
|
+
return self._client.post(self._config.url, headers=headers, content=body)
|
|
124
|
+
|
|
125
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
126
|
+
return client.post(self._config.url, headers=headers, content=body)
|
|
127
|
+
|
|
128
|
+
def is_available(self) -> bool:
|
|
129
|
+
return bool(self._health.get("healthy", False))
|
|
130
|
+
|
|
131
|
+
def get_health(self) -> TransportHealth:
|
|
132
|
+
return cast(TransportHealth, dict(self._health))
|
|
133
|
+
|
|
134
|
+
def get_metrics(self) -> TransportMetrics:
|
|
135
|
+
return cast(TransportMetrics, dict(self._metrics))
|
|
136
|
+
|
|
137
|
+
def destroy(self) -> None:
|
|
138
|
+
if self._client is not None:
|
|
139
|
+
self._client.close()
|
|
140
|
+
self._client = None
|
|
141
|
+
|
|
142
|
+
def _record_success(self, duration_ms: float) -> None:
|
|
143
|
+
self._health = {
|
|
144
|
+
"healthy": True,
|
|
145
|
+
"consecutive_failures": 0,
|
|
146
|
+
"last_successful_send": time.time(),
|
|
147
|
+
}
|
|
148
|
+
successful = self._metrics.get("successful_sends", 0) + 1
|
|
149
|
+
self._metrics["successful_sends"] = successful
|
|
150
|
+
current_avg = float(self._metrics.get("avg_latency", 0.0))
|
|
151
|
+
self._metrics["avg_latency"] = ((current_avg * (successful - 1)) + duration_ms) / successful
|
|
152
|
+
|
|
153
|
+
def _record_failure(self, error: Exception) -> None:
|
|
154
|
+
failures = int(self._health.get("consecutive_failures", 0)) + 1
|
|
155
|
+
self._health = {
|
|
156
|
+
"healthy": False,
|
|
157
|
+
"consecutive_failures": failures,
|
|
158
|
+
"error_message": str(error),
|
|
159
|
+
}
|
|
160
|
+
self._metrics["failed_sends"] = self._metrics.get("failed_sends", 0) + 1
|
|
161
|
+
|
|
162
|
+
def _update_compression_metrics(self, duration_ms: float, raw_size: int, compressed_size: int) -> None:
|
|
163
|
+
successful = max(1, self._metrics.get("successful_sends", 1))
|
|
164
|
+
current_time = float(self._metrics.get("avg_compression_time", 0.0))
|
|
165
|
+
self._metrics["avg_compression_time"] = (
|
|
166
|
+
(current_time * (successful - 1)) + duration_ms
|
|
167
|
+
) / successful
|
|
168
|
+
|
|
169
|
+
ratio = compressed_size / raw_size if raw_size > 0 else 1.0
|
|
170
|
+
current_ratio = float(self._metrics.get("avg_compression_ratio", 0.0))
|
|
171
|
+
self._metrics["avg_compression_ratio"] = (
|
|
172
|
+
(current_ratio * (successful - 1)) + ratio
|
|
173
|
+
) / successful
|
|
174
|
+
|
|
175
|
+
def _format_for_loki(self, entries: list[LogEntryEntity]) -> dict[str, Any]:
|
|
176
|
+
streams: dict[str, dict[str, Any]] = {}
|
|
177
|
+
values_map: dict[str, list[list[str]]] = defaultdict(list)
|
|
178
|
+
|
|
179
|
+
for entry in entries:
|
|
180
|
+
labels = entry.labels or {}
|
|
181
|
+
key = json.dumps(labels, sort_keys=True)
|
|
182
|
+
|
|
183
|
+
payload: dict[str, Any] = {
|
|
184
|
+
"id": entry.id,
|
|
185
|
+
"level": entry.level,
|
|
186
|
+
"message": entry.message,
|
|
187
|
+
"context": entry.context,
|
|
188
|
+
}
|
|
189
|
+
if entry.trace_id:
|
|
190
|
+
payload["traceId"] = entry.trace_id
|
|
191
|
+
if entry.span_id:
|
|
192
|
+
payload["spanId"] = entry.span_id
|
|
193
|
+
if entry.request_id:
|
|
194
|
+
payload["requestId"] = entry.request_id
|
|
195
|
+
if entry.metadata:
|
|
196
|
+
payload["metadata"] = entry.metadata
|
|
197
|
+
|
|
198
|
+
timestamp_ns = self._timestamp_to_ns(entry.timestamp)
|
|
199
|
+
values_map[key].append([str(timestamp_ns), self._dumps(payload).decode("utf-8")])
|
|
200
|
+
streams[key] = labels
|
|
201
|
+
|
|
202
|
+
formatted_streams: list[dict[str, Any]] = []
|
|
203
|
+
for key, stream_labels in streams.items():
|
|
204
|
+
values = values_map[key]
|
|
205
|
+
values.sort(key=lambda item: item[0])
|
|
206
|
+
formatted_streams.append({"stream": stream_labels, "values": values})
|
|
207
|
+
|
|
208
|
+
return {"streams": formatted_streams}
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _timestamp_to_ns(iso_timestamp: str) -> int:
|
|
212
|
+
try:
|
|
213
|
+
normalized = iso_timestamp.replace("Z", "+00:00")
|
|
214
|
+
dt_obj = datetime.fromisoformat(normalized)
|
|
215
|
+
if dt_obj.tzinfo is None:
|
|
216
|
+
dt_obj = dt_obj.replace(tzinfo=timezone.utc)
|
|
217
|
+
seconds = int(dt_obj.timestamp())
|
|
218
|
+
return (seconds * 1_000_000_000) + (dt_obj.microsecond * 1000)
|
|
219
|
+
except Exception:
|
|
220
|
+
return int(time.time() * 1_000_000_000)
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _dumps(payload: Any) -> bytes:
|
|
224
|
+
if orjson is not None:
|
|
225
|
+
return cast(bytes, orjson.dumps(payload))
|
|
226
|
+
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|