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,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]