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,508 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from .config import (
|
|
16
|
+
BufferConfig,
|
|
17
|
+
CircuitBreakerConfig,
|
|
18
|
+
CompressionType,
|
|
19
|
+
DeadLetterQueueConfig,
|
|
20
|
+
DLQType,
|
|
21
|
+
FilterConfig,
|
|
22
|
+
IntegrationsConfig,
|
|
23
|
+
LogsInterceptorConfig,
|
|
24
|
+
PerformanceConfig,
|
|
25
|
+
TransportConfig,
|
|
26
|
+
)
|
|
27
|
+
from .domain.value_objects import LogLevelVO
|
|
28
|
+
from .types import LogLevel
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import orjson # type: ignore[import-not-found]
|
|
32
|
+
except Exception: # pragma: no cover - optional dependency
|
|
33
|
+
orjson = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
TRUE_VALUES = {"1", "true", "yes", "on"}
|
|
37
|
+
FALSE_VALUES = {"0", "false", "no", "off"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_bool(value: str | None, default: bool) -> bool:
|
|
41
|
+
if value is None:
|
|
42
|
+
return default
|
|
43
|
+
normalized = value.strip().lower()
|
|
44
|
+
if normalized in TRUE_VALUES:
|
|
45
|
+
return True
|
|
46
|
+
if normalized in FALSE_VALUES:
|
|
47
|
+
return False
|
|
48
|
+
return default
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_int_range(value: str | None, default: int, min_value: int, max_value: int) -> int:
|
|
52
|
+
if value is None:
|
|
53
|
+
return default
|
|
54
|
+
try:
|
|
55
|
+
parsed = int(value)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return default
|
|
58
|
+
if parsed < min_value or parsed > max_value:
|
|
59
|
+
return default
|
|
60
|
+
return parsed
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_float_range(value: str | None, default: float, min_value: float, max_value: float) -> float:
|
|
64
|
+
if value is None:
|
|
65
|
+
return default
|
|
66
|
+
try:
|
|
67
|
+
parsed = float(value)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return default
|
|
70
|
+
if parsed < min_value or parsed > max_value:
|
|
71
|
+
return default
|
|
72
|
+
return parsed
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_debug_enabled() -> bool:
|
|
76
|
+
return parse_bool(os.getenv("LOGS_DEBUG"), False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_silent_errors_enabled() -> bool:
|
|
80
|
+
return parse_bool(os.getenv("LOGS_SILENT_ERRORS"), False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _internal_log(level: str, message: str, context: Any | None = None) -> None:
|
|
84
|
+
if level == "debug" and not is_debug_enabled():
|
|
85
|
+
return
|
|
86
|
+
if level in {"warn", "error"} and is_silent_errors_enabled():
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
prefix = "[logs-interceptor]"
|
|
90
|
+
payload = f"{prefix} {message}"
|
|
91
|
+
if context is not None:
|
|
92
|
+
payload = f"{payload} {safe_stringify(context)}"
|
|
93
|
+
|
|
94
|
+
stream = sys.stderr if level in {"warn", "error"} else sys.stdout
|
|
95
|
+
stream.write(payload + "\n")
|
|
96
|
+
stream.flush()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def internal_debug(message: str, context: Any | None = None) -> None:
|
|
100
|
+
_internal_log("debug", message, context)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def internal_warn(message: str, context: Any | None = None) -> None:
|
|
104
|
+
_internal_log("warn", message, context)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def internal_error(message: str, context: Any | None = None) -> None:
|
|
108
|
+
_internal_log("error", message, context)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _safe_convert(value: Any, max_depth: int, depth: int, seen: set[int]) -> Any:
|
|
112
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
113
|
+
return value
|
|
114
|
+
if depth > max_depth:
|
|
115
|
+
return "[Max Depth Reached]"
|
|
116
|
+
|
|
117
|
+
if isinstance(value, Exception):
|
|
118
|
+
return {
|
|
119
|
+
"name": value.__class__.__name__,
|
|
120
|
+
"message": str(value),
|
|
121
|
+
"args": value.args,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if isinstance(value, datetime):
|
|
125
|
+
return value.isoformat()
|
|
126
|
+
|
|
127
|
+
obj_id = id(value)
|
|
128
|
+
if obj_id in seen:
|
|
129
|
+
return "[Circular Reference]"
|
|
130
|
+
|
|
131
|
+
if isinstance(value, dict):
|
|
132
|
+
seen.add(obj_id)
|
|
133
|
+
return {
|
|
134
|
+
str(k): _safe_convert(v, max_depth, depth + 1, seen)
|
|
135
|
+
for k, v in value.items()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
139
|
+
seen.add(obj_id)
|
|
140
|
+
return [_safe_convert(v, max_depth, depth + 1, seen) for v in value]
|
|
141
|
+
|
|
142
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
143
|
+
seen.add(obj_id)
|
|
144
|
+
return _safe_convert(asdict(value), max_depth, depth + 1, seen)
|
|
145
|
+
|
|
146
|
+
if hasattr(value, "__dict__"):
|
|
147
|
+
seen.add(obj_id)
|
|
148
|
+
return _safe_convert(value.__dict__, max_depth, depth + 1, seen)
|
|
149
|
+
|
|
150
|
+
return str(value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def safe_stringify(value: Any, max_depth: int = 10) -> str:
|
|
154
|
+
try:
|
|
155
|
+
converted = _safe_convert(value, max_depth=max_depth, depth=0, seen=set())
|
|
156
|
+
if orjson is not None:
|
|
157
|
+
return cast(bytes, orjson.dumps(converted)).decode("utf-8")
|
|
158
|
+
return json.dumps(converted, separators=(",", ":"), ensure_ascii=False)
|
|
159
|
+
except Exception as exc: # pragma: no cover - hard-failure fallback
|
|
160
|
+
return f"[Unserializable: {exc}]"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def detect_sensitive_data(text: str, patterns: Iterable[str]) -> bool:
|
|
164
|
+
compiled = [re.compile(p, re.IGNORECASE) for p in patterns]
|
|
165
|
+
for pattern in compiled:
|
|
166
|
+
if pattern.search(text):
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
common_patterns = [
|
|
170
|
+
re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
|
|
171
|
+
re.compile(r"\b(?:\d{4}[-\s]?){3}\d{4}\b"),
|
|
172
|
+
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
173
|
+
re.compile(r"\b\d{3}\.\d{3}\.\d{3}-\d{2}\b"),
|
|
174
|
+
re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/=]*", re.IGNORECASE),
|
|
175
|
+
re.compile(r"Basic\s+[A-Za-z0-9+/=]*", re.IGNORECASE),
|
|
176
|
+
]
|
|
177
|
+
return any(pattern.search(text) for pattern in common_patterns)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def sanitize_data(
|
|
181
|
+
data: dict[str, Any],
|
|
182
|
+
sensitive_patterns: Iterable[str],
|
|
183
|
+
seen: set[int] | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
if seen is None:
|
|
186
|
+
seen = set()
|
|
187
|
+
|
|
188
|
+
obj_id = id(data)
|
|
189
|
+
if obj_id in seen:
|
|
190
|
+
return {"_circular": "[REDACTED]"}
|
|
191
|
+
seen.add(obj_id)
|
|
192
|
+
|
|
193
|
+
compiled = [re.compile(p, re.IGNORECASE) for p in sensitive_patterns]
|
|
194
|
+
sanitized: dict[str, Any] = {}
|
|
195
|
+
|
|
196
|
+
for key, value in data.items():
|
|
197
|
+
key_sensitive = any(pattern.search(key) for pattern in compiled)
|
|
198
|
+
if key_sensitive:
|
|
199
|
+
sanitized[key] = "[REDACTED]"
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
if isinstance(value, str):
|
|
203
|
+
sanitized[key] = "[REDACTED]" if detect_sensitive_data(value, sensitive_patterns) else value
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if isinstance(value, list):
|
|
207
|
+
transformed: list[Any] = []
|
|
208
|
+
for item in value:
|
|
209
|
+
if isinstance(item, str):
|
|
210
|
+
transformed.append(
|
|
211
|
+
"[REDACTED]" if detect_sensitive_data(item, sensitive_patterns) else item
|
|
212
|
+
)
|
|
213
|
+
elif isinstance(item, dict):
|
|
214
|
+
transformed.append(sanitize_data(item, sensitive_patterns, seen))
|
|
215
|
+
else:
|
|
216
|
+
transformed.append(item)
|
|
217
|
+
sanitized[key] = transformed
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
if isinstance(value, dict):
|
|
221
|
+
sanitized[key] = sanitize_data(value, sensitive_patterns, seen)
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
sanitized[key] = value
|
|
225
|
+
|
|
226
|
+
return sanitized
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def hash_sensitive_data(data: str) -> str:
|
|
230
|
+
return hashlib.sha256(data.encode("utf-8")).hexdigest()[:16]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_labels(labels_string: str) -> dict[str, str]:
|
|
234
|
+
labels: dict[str, str] = {}
|
|
235
|
+
if not labels_string:
|
|
236
|
+
return labels
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
if labels_string.startswith("{"):
|
|
240
|
+
parsed = json.loads(labels_string)
|
|
241
|
+
if isinstance(parsed, dict):
|
|
242
|
+
return {str(k): str(v) for k, v in parsed.items()}
|
|
243
|
+
return labels
|
|
244
|
+
|
|
245
|
+
pairs = labels_string.split(",")
|
|
246
|
+
for pair in pairs:
|
|
247
|
+
key, *value_parts = pair.split("=")
|
|
248
|
+
if key and value_parts:
|
|
249
|
+
labels[key.strip()] = "=".join(value_parts).strip()
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
internal_warn("Failed to parse labels from environment", exc)
|
|
252
|
+
|
|
253
|
+
return labels
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def should_sample(rate: float) -> bool:
|
|
257
|
+
if rate >= 1.0:
|
|
258
|
+
return True
|
|
259
|
+
if rate <= 0.0:
|
|
260
|
+
return False
|
|
261
|
+
return random.random() < rate
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def should_sample_advanced(
|
|
265
|
+
rate: float,
|
|
266
|
+
strategy: str = "random",
|
|
267
|
+
key: str | None = None,
|
|
268
|
+
) -> bool:
|
|
269
|
+
if rate >= 1.0:
|
|
270
|
+
return True
|
|
271
|
+
if rate <= 0.0:
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
if strategy == "deterministic" and key:
|
|
275
|
+
digest = hashlib.md5(key.encode("utf-8")).digest() # noqa: S324 - deterministic sampling
|
|
276
|
+
value = int.from_bytes(digest[:4], "big") / 0xFFFFFFFF
|
|
277
|
+
return value < rate
|
|
278
|
+
|
|
279
|
+
if strategy == "adaptive":
|
|
280
|
+
try:
|
|
281
|
+
load = os.getloadavg()[0]
|
|
282
|
+
cpu_count = max(1, os.cpu_count() or 1)
|
|
283
|
+
factor = min(1.0, load / cpu_count)
|
|
284
|
+
adjusted = rate * (1 - factor * 0.5)
|
|
285
|
+
return random.random() < adjusted
|
|
286
|
+
except OSError:
|
|
287
|
+
return random.random() < rate
|
|
288
|
+
|
|
289
|
+
return random.random() < rate
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def format_bytes(size: int) -> str:
|
|
293
|
+
units = ["Bytes", "KB", "MB", "GB"]
|
|
294
|
+
if size == 0:
|
|
295
|
+
return "0 Bytes"
|
|
296
|
+
index = 0
|
|
297
|
+
value = float(size)
|
|
298
|
+
while value >= 1024 and index < len(units) - 1:
|
|
299
|
+
value /= 1024
|
|
300
|
+
index += 1
|
|
301
|
+
return f"{value:.2f} {units[index]}"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def calculate_compression_ratio(original: int, compressed: int) -> float:
|
|
305
|
+
if original <= 0:
|
|
306
|
+
return 0.0
|
|
307
|
+
return round((1 - (compressed / original)) * 100, 2)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _env_levels(value: str | None) -> list[LogLevel]:
|
|
311
|
+
raw = value or "debug,info,warn,error,fatal"
|
|
312
|
+
levels: list[LogLevel] = []
|
|
313
|
+
for item in raw.split(","):
|
|
314
|
+
normalized = item.strip().lower()
|
|
315
|
+
if LogLevelVO.is_valid(normalized):
|
|
316
|
+
levels.append(normalized) # type: ignore[arg-type]
|
|
317
|
+
return levels
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _merge_dataclass(env_obj: Any | None, user_obj: Any | None) -> Any | None:
|
|
321
|
+
if env_obj is None and user_obj is None:
|
|
322
|
+
return None
|
|
323
|
+
if env_obj is None:
|
|
324
|
+
return user_obj
|
|
325
|
+
if user_obj is None:
|
|
326
|
+
return env_obj
|
|
327
|
+
|
|
328
|
+
if not (is_dataclass(env_obj) and is_dataclass(user_obj)):
|
|
329
|
+
return user_obj
|
|
330
|
+
|
|
331
|
+
merged_values: dict[str, Any] = {}
|
|
332
|
+
for field_info in fields(env_obj):
|
|
333
|
+
env_value = getattr(env_obj, field_info.name)
|
|
334
|
+
user_value = getattr(user_obj, field_info.name)
|
|
335
|
+
merged_values[field_info.name] = user_value if user_value is not None else env_value
|
|
336
|
+
|
|
337
|
+
return cast(Any, env_obj.__class__)(**merged_values)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load_config_from_env() -> LogsInterceptorConfig:
|
|
341
|
+
if not parse_bool(os.getenv("LOGS_ENABLED"), True):
|
|
342
|
+
return LogsInterceptorConfig(filter=FilterConfig(levels=[]))
|
|
343
|
+
|
|
344
|
+
env = os.environ
|
|
345
|
+
labels: dict[str, str] = {}
|
|
346
|
+
for key, value in env.items():
|
|
347
|
+
if key.startswith("LOGS_LABEL_") and value:
|
|
348
|
+
labels[key[len("LOGS_LABEL_") :].lower()] = value
|
|
349
|
+
|
|
350
|
+
compression_value_raw = (env.get("LOGS_COMPRESSION") or "gzip").lower()
|
|
351
|
+
compression_value = compression_value_raw
|
|
352
|
+
if compression_value not in {"none", "gzip", "brotli", "snappy"}:
|
|
353
|
+
compression_value = "gzip"
|
|
354
|
+
|
|
355
|
+
dlq_type_raw = (env.get("LOGS_DLQ_TYPE") or "memory").lower()
|
|
356
|
+
dlq_type = dlq_type_raw
|
|
357
|
+
if dlq_type not in {"memory", "file"}:
|
|
358
|
+
dlq_type = "memory"
|
|
359
|
+
|
|
360
|
+
cfg = LogsInterceptorConfig(
|
|
361
|
+
transport=TransportConfig(
|
|
362
|
+
url=env.get("LOGS_URL", ""),
|
|
363
|
+
tenant_id=env.get("LOGS_TENANT", ""),
|
|
364
|
+
auth_token=env.get("LOGS_TOKEN"),
|
|
365
|
+
timeout=parse_int_range(env.get("LOGS_TIMEOUT"), 10_000, 0, 600_000),
|
|
366
|
+
max_retries=parse_int_range(env.get("LOGS_MAX_RETRIES"), 3, 0, 20),
|
|
367
|
+
retry_delay=parse_int_range(env.get("LOGS_RETRY_DELAY"), 1_000, 0, 120_000),
|
|
368
|
+
compression=cast(CompressionType, compression_value),
|
|
369
|
+
compression_level=parse_int_range(env.get("LOGS_COMPRESSION_LEVEL"), 6, 0, 11),
|
|
370
|
+
compression_threshold=parse_int_range(
|
|
371
|
+
env.get("LOGS_COMPRESSION_THRESHOLD"), 1024, 0, 2**31 - 1
|
|
372
|
+
),
|
|
373
|
+
use_workers=parse_bool(env.get("LOGS_USE_WORKERS"), True),
|
|
374
|
+
max_workers=parse_int_range(env.get("LOGS_MAX_WORKERS"), 2, 1, 64),
|
|
375
|
+
enable_connection_pooling=parse_bool(env.get("LOGS_CONNECTION_POOLING"), True),
|
|
376
|
+
max_sockets=parse_int_range(env.get("LOGS_MAX_SOCKETS"), 50, 1, 1024),
|
|
377
|
+
worker_timeout=parse_int_range(env.get("LOGS_WORKER_TIMEOUT"), 30_000, 1000, 300_000),
|
|
378
|
+
),
|
|
379
|
+
app_name=env.get("LOGS_APP_NAME", ""),
|
|
380
|
+
version=env.get("LOGS_APP_VERSION", "1.0.0"),
|
|
381
|
+
environment=env.get("LOGS_ENVIRONMENT") or env.get("ENVIRONMENT") or "production",
|
|
382
|
+
labels=labels,
|
|
383
|
+
buffer=BufferConfig(
|
|
384
|
+
max_size=parse_int_range(env.get("LOGS_BUFFER_MAX_SIZE"), 100, 1, 1_000_000),
|
|
385
|
+
flush_interval=parse_int_range(
|
|
386
|
+
env.get("LOGS_BUFFER_FLUSH_INTERVAL"), 5000, 1, 300_000
|
|
387
|
+
),
|
|
388
|
+
max_memory_mb=parse_int_range(env.get("LOGS_BUFFER_MAX_MEMORY_MB"), 50, 1, 32_768),
|
|
389
|
+
max_age=parse_int_range(env.get("LOGS_BUFFER_MAX_AGE"), 30_000, 100, 86_400_000),
|
|
390
|
+
auto_flush=parse_bool(env.get("LOGS_BUFFER_AUTO_FLUSH"), True),
|
|
391
|
+
),
|
|
392
|
+
filter=FilterConfig(
|
|
393
|
+
levels=_env_levels(env.get("LOGS_FILTER_LEVELS")),
|
|
394
|
+
sampling_rate=parse_float_range(env.get("LOGS_FILTER_SAMPLING_RATE"), 1.0, 0.0, 1.0),
|
|
395
|
+
sanitize=parse_bool(env.get("LOGS_FILTER_SANITIZE"), True),
|
|
396
|
+
max_message_length=parse_int_range(
|
|
397
|
+
env.get("LOGS_FILTER_MAX_MESSAGE_LENGTH"), 8192, 64, 1_000_000
|
|
398
|
+
),
|
|
399
|
+
),
|
|
400
|
+
circuit_breaker=CircuitBreakerConfig(
|
|
401
|
+
enabled=parse_bool(env.get("LOGS_CIRCUIT_BREAKER_ENABLED"), True),
|
|
402
|
+
failure_threshold=parse_int_range(
|
|
403
|
+
env.get("LOGS_CIRCUIT_BREAKER_FAILURE_THRESHOLD"), 50, 1, 100_000
|
|
404
|
+
),
|
|
405
|
+
reset_timeout=parse_int_range(
|
|
406
|
+
env.get("LOGS_CIRCUIT_BREAKER_RESET_TIMEOUT"), 30_000, 1000, 3_600_000
|
|
407
|
+
),
|
|
408
|
+
half_open_requests=parse_int_range(
|
|
409
|
+
env.get("LOGS_CIRCUIT_BREAKER_HALF_OPEN_REQUESTS"), 3, 1, 100
|
|
410
|
+
),
|
|
411
|
+
),
|
|
412
|
+
dead_letter_queue=DeadLetterQueueConfig(
|
|
413
|
+
enabled=parse_bool(env.get("LOGS_DLQ_ENABLED"), True),
|
|
414
|
+
type=cast(DLQType, dlq_type),
|
|
415
|
+
max_size=parse_int_range(env.get("LOGS_DLQ_MAX_SIZE"), 1000, 1, 1_000_000),
|
|
416
|
+
max_retries=parse_int_range(env.get("LOGS_DLQ_MAX_RETRIES"), 3, 0, 100),
|
|
417
|
+
base_path=env.get("LOGS_DLQ_BASE_PATH") or os.getcwd(),
|
|
418
|
+
max_file_size_mb=10,
|
|
419
|
+
),
|
|
420
|
+
performance=PerformanceConfig(
|
|
421
|
+
use_workers=parse_bool(env.get("LOGS_USE_WORKERS"), True),
|
|
422
|
+
max_concurrent_flushes=parse_int_range(
|
|
423
|
+
env.get("LOGS_MAX_CONCURRENT_FLUSHES"), 3, 1, 256
|
|
424
|
+
),
|
|
425
|
+
max_workers=parse_int_range(env.get("LOGS_MAX_WORKERS"), 2, 1, 64),
|
|
426
|
+
compression_level=parse_int_range(env.get("LOGS_COMPRESSION_LEVEL"), 6, 0, 11),
|
|
427
|
+
worker_timeout=parse_int_range(env.get("LOGS_WORKER_TIMEOUT"), 30_000, 1000, 300_000),
|
|
428
|
+
),
|
|
429
|
+
integrations=IntegrationsConfig(),
|
|
430
|
+
intercept_console=parse_bool(env.get("LOGS_INTERCEPT_CONSOLE"), False),
|
|
431
|
+
preserve_original_console=parse_bool(env.get("LOGS_PRESERVE_ORIGINAL_CONSOLE"), True),
|
|
432
|
+
enable_metrics=parse_bool(env.get("LOGS_ENABLE_METRICS"), True),
|
|
433
|
+
enable_health_check=parse_bool(env.get("LOGS_ENABLE_HEALTH_CHECK"), True),
|
|
434
|
+
debug=parse_bool(env.get("LOGS_DEBUG"), False),
|
|
435
|
+
silent_errors=parse_bool(env.get("LOGS_SILENT_ERRORS"), False),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if not cfg.transport.url and not cfg.transport.tenant_id and not cfg.app_name:
|
|
439
|
+
return LogsInterceptorConfig()
|
|
440
|
+
|
|
441
|
+
return cfg
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def merge_configs(user_config: LogsInterceptorConfig, env_config: LogsInterceptorConfig) -> LogsInterceptorConfig:
|
|
445
|
+
return LogsInterceptorConfig(
|
|
446
|
+
transport=_merge_dataclass(env_config.transport, user_config.transport)
|
|
447
|
+
or TransportConfig(),
|
|
448
|
+
app_name=user_config.app_name or env_config.app_name,
|
|
449
|
+
version=user_config.version or env_config.version,
|
|
450
|
+
environment=user_config.environment or env_config.environment,
|
|
451
|
+
labels={**(env_config.labels or {}), **(user_config.labels or {})} or None,
|
|
452
|
+
dynamic_labels={**(env_config.dynamic_labels or {}), **(user_config.dynamic_labels or {})}
|
|
453
|
+
or None,
|
|
454
|
+
buffer=_merge_dataclass(env_config.buffer, user_config.buffer),
|
|
455
|
+
filter=_merge_dataclass(env_config.filter, user_config.filter),
|
|
456
|
+
circuit_breaker=_merge_dataclass(env_config.circuit_breaker, user_config.circuit_breaker),
|
|
457
|
+
integrations=_merge_dataclass(env_config.integrations, user_config.integrations),
|
|
458
|
+
performance=_merge_dataclass(env_config.performance, user_config.performance),
|
|
459
|
+
dead_letter_queue=_merge_dataclass(env_config.dead_letter_queue, user_config.dead_letter_queue),
|
|
460
|
+
enable_metrics=user_config.enable_metrics
|
|
461
|
+
if user_config.enable_metrics is not None
|
|
462
|
+
else env_config.enable_metrics,
|
|
463
|
+
enable_health_check=user_config.enable_health_check
|
|
464
|
+
if user_config.enable_health_check is not None
|
|
465
|
+
else env_config.enable_health_check,
|
|
466
|
+
intercept_console=user_config.intercept_console
|
|
467
|
+
if user_config.intercept_console is not None
|
|
468
|
+
else env_config.intercept_console,
|
|
469
|
+
preserve_original_console=user_config.preserve_original_console
|
|
470
|
+
if user_config.preserve_original_console is not None
|
|
471
|
+
else env_config.preserve_original_console,
|
|
472
|
+
debug=user_config.debug if user_config.debug is not None else env_config.debug,
|
|
473
|
+
silent_errors=user_config.silent_errors
|
|
474
|
+
if user_config.silent_errors is not None
|
|
475
|
+
else env_config.silent_errors,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def create_correlation_id() -> str:
|
|
480
|
+
return secrets.token_hex(16)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def extract_error_metadata(error: BaseException) -> dict[str, Any]:
|
|
484
|
+
payload: dict[str, Any] = {
|
|
485
|
+
"name": error.__class__.__name__,
|
|
486
|
+
"message": str(error),
|
|
487
|
+
}
|
|
488
|
+
for attr in ["code", "status_code", "errno", "path", "address", "port"]:
|
|
489
|
+
if hasattr(error, attr):
|
|
490
|
+
payload[attr] = getattr(error, attr)
|
|
491
|
+
return payload
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def parse_stack_trace(stack: str) -> list[dict[str, Any]]:
|
|
495
|
+
frames: list[dict[str, Any]] = []
|
|
496
|
+
pattern = re.compile(r"File \"(?P<file>.+?)\", line (?P<line>\d+), in (?P<func>.+)")
|
|
497
|
+
for line in stack.splitlines():
|
|
498
|
+
match = pattern.search(line)
|
|
499
|
+
if not match:
|
|
500
|
+
continue
|
|
501
|
+
frames.append(
|
|
502
|
+
{
|
|
503
|
+
"function": match.group("func"),
|
|
504
|
+
"file": match.group("file"),
|
|
505
|
+
"line": int(match.group("line")),
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
return frames[:10]
|