elven-logs-interceptor-python 0.1.10__tar.gz → 0.1.12__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.10 → elven_logs_interceptor_python-0.1.12}/PKG-INFO +13 -2
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/README.md +12 -1
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/pyproject.toml +1 -1
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/__init__.py +2 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/application/config_service.py +12 -1
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/config.py +5 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/__init__.py +2 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/transport/__init__.py +2 -0
- elven_logs_interceptor_python-0.1.12/src/logs_interceptor/infrastructure/transport/otlp_json_transport.py +260 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/transport/transport_factory.py +5 -1
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/utils.py +39 -1
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/.gitignore +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/ARCHITECTURE.md +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/application/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/application/log_service.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/domain/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/domain/entities.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/domain/interfaces.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/domain/value_objects.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/buffer/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/buffer/memory_buffer.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/circuit_breaker/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/circuit_breaker/circuit_breaker.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/base.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/brotli_compressor.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/factory.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/gzip_compressor.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/noop_compressor.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/context/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/context/context_provider.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/dlq/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/dlq/file_dlq.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/dlq/memory_dlq.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/filter/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/filter/log_filter.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/interceptors/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/interceptors/runtime_interceptor.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/internal_capture_guard.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/log_noise_filter.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/log_record_extra.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/memory/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/memory/memory_tracker.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/metrics/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/metrics/metrics_collector.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/transport/loki_json_transport.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/transport/loki_protobuf_transport.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/transport/resilient_transport.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/workers/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/workers/worker_pool.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/celery.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/django.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/fastapi.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/flask.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/logging_handler.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/loguru.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/structlog.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/preload.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/presentation/__init__.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/presentation/factory.py +0 -0
- {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: elven-logs-interceptor-python
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.12
|
|
4
4
|
Summary: Production-grade logs interceptor for Python with Loki transport, resilience, batching, and framework integrations.
|
|
5
5
|
Author: Elven Observability
|
|
6
6
|
License: MIT
|
|
@@ -132,12 +132,20 @@ data is explicitly required and approved.
|
|
|
132
132
|
The package supports the Python `LOGS_*` configuration surface aligned with
|
|
133
133
|
the JS v3 design where applicable.
|
|
134
134
|
|
|
135
|
-
Required:
|
|
135
|
+
Required for direct Loki mode:
|
|
136
136
|
|
|
137
137
|
- `LOGS_URL`
|
|
138
138
|
- `LOGS_TENANT`
|
|
139
139
|
- `LOGS_APP_NAME`
|
|
140
140
|
|
|
141
|
+
For collector-first mode, set `LOGS_EXPORTER=otlp` (or `collector`) and use
|
|
142
|
+
`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`. In this mode `LOGS_URL`, `LOKI_URL`,
|
|
143
|
+
`LOGS_TENANT` and `LOGS_TOKEN` are not required by the library. `LOGS_URL` is
|
|
144
|
+
intentionally ignored in collector mode to avoid accidentally posting OTLP logs
|
|
145
|
+
to a direct Loki `/loki/api/v1/push` endpoint. Put tenant or auth headers in
|
|
146
|
+
`OTEL_EXPORTER_OTLP_HEADERS`/`LOGS_OTLP_HEADERS` if your collector gateway
|
|
147
|
+
requires them.
|
|
148
|
+
|
|
141
149
|
Core:
|
|
142
150
|
|
|
143
151
|
- `LOGS_TOKEN`
|
|
@@ -146,6 +154,9 @@ Core:
|
|
|
146
154
|
|
|
147
155
|
Transport:
|
|
148
156
|
|
|
157
|
+
- `LOGS_EXPORTER` (`loki|otlp`, aliases: `collector`, `otel`)
|
|
158
|
+
- `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` (collector logs endpoint, e.g. `http://collector:4318/v1/logs`)
|
|
159
|
+
- `LOGS_OTLP_HEADERS` (comma-separated headers, e.g. `x-tenant=team-a`)
|
|
149
160
|
- `LOGS_COMPRESSION` (`none|gzip|brotli|snappy`)
|
|
150
161
|
- `LOGS_COMPRESSION_LEVEL`
|
|
151
162
|
- `LOGS_COMPRESSION_THRESHOLD`
|
|
@@ -71,12 +71,20 @@ data is explicitly required and approved.
|
|
|
71
71
|
The package supports the Python `LOGS_*` configuration surface aligned with
|
|
72
72
|
the JS v3 design where applicable.
|
|
73
73
|
|
|
74
|
-
Required:
|
|
74
|
+
Required for direct Loki mode:
|
|
75
75
|
|
|
76
76
|
- `LOGS_URL`
|
|
77
77
|
- `LOGS_TENANT`
|
|
78
78
|
- `LOGS_APP_NAME`
|
|
79
79
|
|
|
80
|
+
For collector-first mode, set `LOGS_EXPORTER=otlp` (or `collector`) and use
|
|
81
|
+
`OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`. In this mode `LOGS_URL`, `LOKI_URL`,
|
|
82
|
+
`LOGS_TENANT` and `LOGS_TOKEN` are not required by the library. `LOGS_URL` is
|
|
83
|
+
intentionally ignored in collector mode to avoid accidentally posting OTLP logs
|
|
84
|
+
to a direct Loki `/loki/api/v1/push` endpoint. Put tenant or auth headers in
|
|
85
|
+
`OTEL_EXPORTER_OTLP_HEADERS`/`LOGS_OTLP_HEADERS` if your collector gateway
|
|
86
|
+
requires them.
|
|
87
|
+
|
|
80
88
|
Core:
|
|
81
89
|
|
|
82
90
|
- `LOGS_TOKEN`
|
|
@@ -85,6 +93,9 @@ Core:
|
|
|
85
93
|
|
|
86
94
|
Transport:
|
|
87
95
|
|
|
96
|
+
- `LOGS_EXPORTER` (`loki|otlp`, aliases: `collector`, `otel`)
|
|
97
|
+
- `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` (collector logs endpoint, e.g. `http://collector:4318/v1/logs`)
|
|
98
|
+
- `LOGS_OTLP_HEADERS` (comma-separated headers, e.g. `x-tenant=team-a`)
|
|
88
99
|
- `LOGS_COMPRESSION` (`none|gzip|brotli|snappy`)
|
|
89
100
|
- `LOGS_COMPRESSION_LEVEL`
|
|
90
101
|
- `LOGS_COMPRESSION_THRESHOLD`
|
{elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/pyproject.toml
RENAMED
|
@@ -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.12"
|
|
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"
|
|
@@ -88,9 +88,11 @@ def _coerce_config(user_config: LogsInterceptorConfig | dict[str, Any] | None) -
|
|
|
88
88
|
|
|
89
89
|
return LogsInterceptorConfig(
|
|
90
90
|
transport=TransportConfig(
|
|
91
|
+
exporter=_pick(transport_raw, "exporter", "exporter"),
|
|
91
92
|
url=_pick(transport_raw, "url", "url", ""),
|
|
92
93
|
tenant_id=_pick(transport_raw, "tenant_id", "tenantId", ""),
|
|
93
94
|
auth_token=_pick(transport_raw, "auth_token", "authToken"),
|
|
95
|
+
headers=_pick(transport_raw, "headers", "headers"),
|
|
94
96
|
timeout=_pick(transport_raw, "timeout", "timeout"),
|
|
95
97
|
max_retries=_pick(transport_raw, "max_retries", "maxRetries"),
|
|
96
98
|
retry_delay=_pick(transport_raw, "retry_delay", "retryDelay"),
|
|
@@ -7,6 +7,7 @@ from ..config import (
|
|
|
7
7
|
CircuitBreakerConfig,
|
|
8
8
|
FilterConfig,
|
|
9
9
|
IntegrationsConfig,
|
|
10
|
+
LogsExporterType,
|
|
10
11
|
LogsInterceptorConfig,
|
|
11
12
|
PerformanceConfig,
|
|
12
13
|
ResolvedBufferConfig,
|
|
@@ -40,7 +41,7 @@ class ConfigService:
|
|
|
40
41
|
|
|
41
42
|
if not config.transport.url:
|
|
42
43
|
errors.append("Transport URL is required")
|
|
43
|
-
if not config.transport.tenant_id:
|
|
44
|
+
if ConfigService._resolve_exporter(config.transport.exporter) == "loki" and not config.transport.tenant_id:
|
|
44
45
|
errors.append("Tenant ID is required")
|
|
45
46
|
if not config.app_name:
|
|
46
47
|
errors.append("App name is required")
|
|
@@ -125,6 +126,7 @@ class ConfigService:
|
|
|
125
126
|
transport: TransportConfig,
|
|
126
127
|
performance: PerformanceConfig | None,
|
|
127
128
|
) -> ResolvedTransportConfig:
|
|
129
|
+
exporter = ConfigService._resolve_exporter(transport.exporter)
|
|
128
130
|
compression = "gzip"
|
|
129
131
|
if transport.compression in (False, "none"):
|
|
130
132
|
compression = "none"
|
|
@@ -139,6 +141,7 @@ class ConfigService:
|
|
|
139
141
|
url=transport.url,
|
|
140
142
|
tenant_id=transport.tenant_id,
|
|
141
143
|
auth_token=transport.auth_token or "",
|
|
144
|
+
headers=transport.headers or {},
|
|
142
145
|
timeout=transport.timeout if transport.timeout is not None else 10_000,
|
|
143
146
|
max_retries=transport.max_retries if transport.max_retries is not None else 3,
|
|
144
147
|
retry_delay=transport.retry_delay if transport.retry_delay is not None else 1_000,
|
|
@@ -175,8 +178,16 @@ class ConfigService:
|
|
|
175
178
|
if transport.enable_structured_metadata is None
|
|
176
179
|
else transport.enable_structured_metadata
|
|
177
180
|
),
|
|
181
|
+
exporter=exporter,
|
|
178
182
|
)
|
|
179
183
|
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _resolve_exporter(value: str | None) -> LogsExporterType:
|
|
186
|
+
normalized = (value or "loki").strip().lower()
|
|
187
|
+
if normalized in {"otlp", "collector", "otel"}:
|
|
188
|
+
return "otlp"
|
|
189
|
+
return "loki"
|
|
190
|
+
|
|
180
191
|
@staticmethod
|
|
181
192
|
def _resolve_buffer(buffer: BufferConfig | None) -> ResolvedBufferConfig:
|
|
182
193
|
source = buffer or BufferConfig()
|
|
@@ -8,13 +8,16 @@ from .types import LogLevel
|
|
|
8
8
|
|
|
9
9
|
CompressionType = Literal["none", "gzip", "brotli", "snappy"]
|
|
10
10
|
DLQType = Literal["memory", "file"]
|
|
11
|
+
LogsExporterType = Literal["loki", "otlp"]
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@dataclass(slots=True)
|
|
14
15
|
class TransportConfig:
|
|
16
|
+
exporter: LogsExporterType | str | None = None
|
|
15
17
|
url: str = ""
|
|
16
18
|
tenant_id: str = ""
|
|
17
19
|
auth_token: str | None = None
|
|
20
|
+
headers: dict[str, str] | None = None
|
|
18
21
|
timeout: int | None = None
|
|
19
22
|
max_retries: int | None = None
|
|
20
23
|
retry_delay: int | None = None
|
|
@@ -133,6 +136,8 @@ class ResolvedTransportConfig:
|
|
|
133
136
|
enable_connection_pooling: bool
|
|
134
137
|
max_sockets: int
|
|
135
138
|
enable_structured_metadata: bool = False
|
|
139
|
+
exporter: LogsExporterType = "loki"
|
|
140
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
136
141
|
|
|
137
142
|
|
|
138
143
|
@dataclass(slots=True)
|
|
@@ -17,6 +17,7 @@ from .metrics import MetricsCollector
|
|
|
17
17
|
from .transport import (
|
|
18
18
|
LokiJsonTransport,
|
|
19
19
|
LokiProtobufTransport,
|
|
20
|
+
OtlpJsonTransport,
|
|
20
21
|
ResilientTransport,
|
|
21
22
|
ResilientTransportConfig,
|
|
22
23
|
TransportFactory,
|
|
@@ -41,6 +42,7 @@ __all__ = [
|
|
|
41
42
|
"MetricsCollector",
|
|
42
43
|
"LokiJsonTransport",
|
|
43
44
|
"LokiProtobufTransport",
|
|
45
|
+
"OtlpJsonTransport",
|
|
44
46
|
"ResilientTransport",
|
|
45
47
|
"ResilientTransportConfig",
|
|
46
48
|
"TransportFactory",
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from .loki_json_transport import LokiJsonTransport
|
|
2
2
|
from .loki_protobuf_transport import LokiProtobufTransport
|
|
3
|
+
from .otlp_json_transport import OtlpJsonTransport
|
|
3
4
|
from .resilient_transport import ResilientTransport, ResilientTransportConfig
|
|
4
5
|
from .transport_factory import TransportFactory
|
|
5
6
|
|
|
6
7
|
__all__ = [
|
|
7
8
|
"LokiJsonTransport",
|
|
8
9
|
"LokiProtobufTransport",
|
|
10
|
+
"OtlpJsonTransport",
|
|
9
11
|
"ResilientTransport",
|
|
10
12
|
"ResilientTransportConfig",
|
|
11
13
|
"TransportFactory",
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ...config import ResolvedLogsInterceptorConfig
|
|
14
|
+
from ...domain.entities import LogEntryEntity
|
|
15
|
+
from ...types import TransportHealth, TransportMetrics
|
|
16
|
+
from ...utils import get_distribution_version
|
|
17
|
+
from ..internal_capture_guard import suppress_internal_log_capture
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
orjson = cast(Any, import_module("orjson"))
|
|
21
|
+
except Exception: # pragma: no cover - optional dependency
|
|
22
|
+
orjson = None
|
|
23
|
+
|
|
24
|
+
_HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
|
|
25
|
+
_SEVERITY_NUMBER = {
|
|
26
|
+
"debug": 5,
|
|
27
|
+
"info": 9,
|
|
28
|
+
"warn": 13,
|
|
29
|
+
"error": 17,
|
|
30
|
+
"fatal": 21,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class RetryableTransportError(Exception):
|
|
36
|
+
message: str
|
|
37
|
+
status_code: int | None = None
|
|
38
|
+
retryable: bool = False
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return self.message
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OtlpJsonTransport:
|
|
45
|
+
"""OTLP/HTTP JSON logs transport for collector-first deployments."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: ResolvedLogsInterceptorConfig) -> None:
|
|
48
|
+
self._config = config
|
|
49
|
+
self._transport = config.transport
|
|
50
|
+
self._timeout = self._transport.timeout / 1000
|
|
51
|
+
limits = httpx.Limits(
|
|
52
|
+
max_keepalive_connections=self._transport.max_sockets,
|
|
53
|
+
max_connections=self._transport.max_sockets,
|
|
54
|
+
)
|
|
55
|
+
self._client: httpx.Client | None = None
|
|
56
|
+
if self._transport.enable_connection_pooling:
|
|
57
|
+
self._client = httpx.Client(timeout=self._timeout, limits=limits)
|
|
58
|
+
|
|
59
|
+
self._health: TransportHealth = {
|
|
60
|
+
"healthy": True,
|
|
61
|
+
"consecutive_failures": 0,
|
|
62
|
+
}
|
|
63
|
+
self._metrics: TransportMetrics = {
|
|
64
|
+
"total_sends": 0,
|
|
65
|
+
"successful_sends": 0,
|
|
66
|
+
"failed_sends": 0,
|
|
67
|
+
"avg_latency": 0.0,
|
|
68
|
+
"avg_compression_time": 0.0,
|
|
69
|
+
"avg_compression_ratio": 0.0,
|
|
70
|
+
"total_bytes_sent": 0,
|
|
71
|
+
"total_bytes_compressed": 0,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def send(self, entries: list[LogEntryEntity]) -> None:
|
|
75
|
+
if not entries:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
start = time.perf_counter()
|
|
79
|
+
self._metrics["total_sends"] = self._metrics.get("total_sends", 0) + 1
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
body = self._dumps(self._format_for_otlp(entries))
|
|
83
|
+
headers = {
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
"User-Agent": (
|
|
86
|
+
"elven-logs-interceptor-python/"
|
|
87
|
+
f"{get_distribution_version('elven-logs-interceptor-python')}"
|
|
88
|
+
),
|
|
89
|
+
**self._transport.headers,
|
|
90
|
+
}
|
|
91
|
+
if self._transport.auth_token:
|
|
92
|
+
headers["Authorization"] = f"Bearer {self._transport.auth_token}"
|
|
93
|
+
|
|
94
|
+
with suppress_internal_log_capture():
|
|
95
|
+
response = self._request(headers, body)
|
|
96
|
+
|
|
97
|
+
if response.status_code >= 300:
|
|
98
|
+
raise RetryableTransportError(
|
|
99
|
+
message=f"OTLP logs endpoint responded with {response.status_code}: {response.text}",
|
|
100
|
+
status_code=response.status_code,
|
|
101
|
+
retryable=response.status_code == 429 or response.status_code >= 500,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
latency = (time.perf_counter() - start) * 1000
|
|
105
|
+
self._record_success(latency)
|
|
106
|
+
self._metrics["total_bytes_sent"] = self._metrics.get("total_bytes_sent", 0) + len(body)
|
|
107
|
+
self._metrics["total_bytes_compressed"] = self._metrics.get("total_bytes_compressed", 0) + len(body)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
self._record_failure(exc)
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
def _request(self, headers: dict[str, str], body: bytes) -> httpx.Response:
|
|
113
|
+
if self._client is not None:
|
|
114
|
+
return self._client.post(self._transport.url, headers=headers, content=body)
|
|
115
|
+
|
|
116
|
+
with httpx.Client(timeout=self._timeout) as client:
|
|
117
|
+
return client.post(self._transport.url, headers=headers, content=body)
|
|
118
|
+
|
|
119
|
+
def is_available(self) -> bool:
|
|
120
|
+
return bool(self._health.get("healthy", False))
|
|
121
|
+
|
|
122
|
+
def get_health(self) -> TransportHealth:
|
|
123
|
+
return cast(TransportHealth, dict(self._health))
|
|
124
|
+
|
|
125
|
+
def get_metrics(self) -> TransportMetrics:
|
|
126
|
+
return cast(TransportMetrics, dict(self._metrics))
|
|
127
|
+
|
|
128
|
+
def destroy(self) -> None:
|
|
129
|
+
if self._client is not None:
|
|
130
|
+
self._client.close()
|
|
131
|
+
self._client = None
|
|
132
|
+
|
|
133
|
+
def _record_success(self, duration_ms: float) -> None:
|
|
134
|
+
self._health = {
|
|
135
|
+
"healthy": True,
|
|
136
|
+
"consecutive_failures": 0,
|
|
137
|
+
"last_successful_send": time.time(),
|
|
138
|
+
}
|
|
139
|
+
successful = self._metrics.get("successful_sends", 0) + 1
|
|
140
|
+
self._metrics["successful_sends"] = successful
|
|
141
|
+
current_avg = float(self._metrics.get("avg_latency", 0.0))
|
|
142
|
+
self._metrics["avg_latency"] = ((current_avg * (successful - 1)) + duration_ms) / successful
|
|
143
|
+
|
|
144
|
+
def _record_failure(self, error: Exception) -> None:
|
|
145
|
+
failures = int(self._health.get("consecutive_failures", 0)) + 1
|
|
146
|
+
self._health = {
|
|
147
|
+
"healthy": False,
|
|
148
|
+
"consecutive_failures": failures,
|
|
149
|
+
"error_message": str(error),
|
|
150
|
+
}
|
|
151
|
+
self._metrics["failed_sends"] = self._metrics.get("failed_sends", 0) + 1
|
|
152
|
+
|
|
153
|
+
def _format_for_otlp(self, entries: list[LogEntryEntity]) -> dict[str, Any]:
|
|
154
|
+
return {
|
|
155
|
+
"resourceLogs": [
|
|
156
|
+
{
|
|
157
|
+
"resource": {"attributes": self._resource_attributes()},
|
|
158
|
+
"scopeLogs": [
|
|
159
|
+
{
|
|
160
|
+
"scope": {
|
|
161
|
+
"name": "elven-logs-interceptor-python",
|
|
162
|
+
"version": get_distribution_version("elven-logs-interceptor-python"),
|
|
163
|
+
},
|
|
164
|
+
"logRecords": [self._format_record(entry) for entry in entries],
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def _resource_attributes(self) -> list[dict[str, Any]]:
|
|
172
|
+
attrs: dict[str, Any] = {
|
|
173
|
+
"service.name": self._config.app_name,
|
|
174
|
+
"service.version": self._config.version,
|
|
175
|
+
"deployment.environment": self._config.environment,
|
|
176
|
+
**self._config.labels,
|
|
177
|
+
}
|
|
178
|
+
return self._attributes(attrs)
|
|
179
|
+
|
|
180
|
+
def _format_record(self, entry: LogEntryEntity) -> dict[str, Any]:
|
|
181
|
+
record: dict[str, Any] = {
|
|
182
|
+
"timeUnixNano": str(self._timestamp_to_ns(entry.timestamp)),
|
|
183
|
+
"observedTimeUnixNano": str(int(time.time() * 1_000_000_000)),
|
|
184
|
+
"severityText": entry.level.upper(),
|
|
185
|
+
"severityNumber": _SEVERITY_NUMBER.get(entry.level, 9),
|
|
186
|
+
"body": {"stringValue": entry.message},
|
|
187
|
+
"attributes": self._entry_attributes(entry),
|
|
188
|
+
}
|
|
189
|
+
if entry.trace_id and self._valid_hex(entry.trace_id, 32):
|
|
190
|
+
record["traceId"] = entry.trace_id.lower()
|
|
191
|
+
if entry.span_id and self._valid_hex(entry.span_id, 16):
|
|
192
|
+
record["spanId"] = entry.span_id.lower()
|
|
193
|
+
return record
|
|
194
|
+
|
|
195
|
+
def _entry_attributes(self, entry: LogEntryEntity) -> list[dict[str, Any]]:
|
|
196
|
+
attrs: dict[str, Any] = {
|
|
197
|
+
"log.id": entry.id,
|
|
198
|
+
"log.level": entry.level,
|
|
199
|
+
}
|
|
200
|
+
if entry.request_id:
|
|
201
|
+
attrs["request.id"] = entry.request_id
|
|
202
|
+
if entry.context:
|
|
203
|
+
attrs["context"] = entry.context
|
|
204
|
+
if entry.metadata:
|
|
205
|
+
attrs["metadata"] = entry.metadata
|
|
206
|
+
if entry.labels:
|
|
207
|
+
attrs["labels"] = entry.labels
|
|
208
|
+
return self._attributes(attrs)
|
|
209
|
+
|
|
210
|
+
def _attributes(self, attrs: dict[str, Any]) -> list[dict[str, Any]]:
|
|
211
|
+
return [
|
|
212
|
+
{"key": str(key), "value": self._any_value(value)}
|
|
213
|
+
for key, value in attrs.items()
|
|
214
|
+
if value is not None
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
def _any_value(self, value: Any) -> dict[str, Any]:
|
|
218
|
+
if isinstance(value, bool):
|
|
219
|
+
return {"boolValue": value}
|
|
220
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
221
|
+
return {"intValue": str(value)}
|
|
222
|
+
if isinstance(value, float):
|
|
223
|
+
return {"doubleValue": value}
|
|
224
|
+
if isinstance(value, str):
|
|
225
|
+
return {"stringValue": value}
|
|
226
|
+
if isinstance(value, (list, tuple)):
|
|
227
|
+
return {"arrayValue": {"values": [self._any_value(item) for item in value]}}
|
|
228
|
+
if isinstance(value, dict):
|
|
229
|
+
return {
|
|
230
|
+
"kvlistValue": {
|
|
231
|
+
"values": [
|
|
232
|
+
{"key": str(key), "value": self._any_value(item)}
|
|
233
|
+
for key, item in value.items()
|
|
234
|
+
if item is not None
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {"stringValue": str(value)}
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _valid_hex(value: str, length: int) -> bool:
|
|
242
|
+
return len(value) == length and bool(_HEX_RE.match(value)) and set(value) != {"0"}
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _timestamp_to_ns(iso_timestamp: str) -> int:
|
|
246
|
+
try:
|
|
247
|
+
normalized = iso_timestamp.replace("Z", "+00:00")
|
|
248
|
+
dt_obj = datetime.fromisoformat(normalized)
|
|
249
|
+
if dt_obj.tzinfo is None:
|
|
250
|
+
dt_obj = dt_obj.replace(tzinfo=timezone.utc)
|
|
251
|
+
seconds = int(dt_obj.timestamp())
|
|
252
|
+
return (seconds * 1_000_000_000) + (dt_obj.microsecond * 1000)
|
|
253
|
+
except Exception:
|
|
254
|
+
return int(time.time() * 1_000_000_000)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _dumps(payload: Any) -> bytes:
|
|
258
|
+
if orjson is not None:
|
|
259
|
+
return cast(bytes, orjson.dumps(payload))
|
|
260
|
+
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
@@ -5,6 +5,7 @@ from ...domain.interfaces import ICircuitBreaker, IDeadLetterQueue, ILogTranspor
|
|
|
5
5
|
from ...utils import internal_debug, internal_warn
|
|
6
6
|
from .loki_json_transport import LokiJsonTransport
|
|
7
7
|
from .loki_protobuf_transport import LokiProtobufTransport
|
|
8
|
+
from .otlp_json_transport import OtlpJsonTransport
|
|
8
9
|
from .resilient_transport import ResilientTransport, ResilientTransportConfig
|
|
9
10
|
|
|
10
11
|
|
|
@@ -17,7 +18,10 @@ class TransportFactory:
|
|
|
17
18
|
) -> ILogTransport:
|
|
18
19
|
base_transport: ILogTransport
|
|
19
20
|
|
|
20
|
-
if config.transport.
|
|
21
|
+
if config.transport.exporter == "otlp":
|
|
22
|
+
internal_debug("Selected OtlpJsonTransport")
|
|
23
|
+
base_transport = OtlpJsonTransport(config)
|
|
24
|
+
elif config.transport.compression == "snappy":
|
|
21
25
|
try:
|
|
22
26
|
internal_debug("Selected LokiProtobufTransport")
|
|
23
27
|
base_transport = LokiProtobufTransport(config.transport)
|
|
@@ -75,6 +75,33 @@ def parse_float_range(value: str | None, default: float, min_value: float, max_v
|
|
|
75
75
|
return parsed
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def _normalize_logs_exporter(value: str | None) -> str:
|
|
79
|
+
normalized = (value or "loki").strip().lower()
|
|
80
|
+
if normalized in {"otlp", "collector", "otel"}:
|
|
81
|
+
return "otlp"
|
|
82
|
+
return "loki"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_kv_csv(raw: str | None) -> dict[str, str]:
|
|
86
|
+
headers: dict[str, str] = {}
|
|
87
|
+
if not raw:
|
|
88
|
+
return headers
|
|
89
|
+
for item in raw.split(","):
|
|
90
|
+
if "=" not in item:
|
|
91
|
+
continue
|
|
92
|
+
key, value = item.split("=", 1)
|
|
93
|
+
key = key.strip()
|
|
94
|
+
if key:
|
|
95
|
+
headers[key] = value.strip()
|
|
96
|
+
return headers
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_logs_url(env: Mapping[str, str], exporter: str) -> str:
|
|
100
|
+
if exporter != "otlp":
|
|
101
|
+
return env.get("LOGS_URL", "")
|
|
102
|
+
return env.get("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "")
|
|
103
|
+
|
|
104
|
+
|
|
78
105
|
def is_debug_enabled() -> bool:
|
|
79
106
|
return parse_bool(os.getenv("LOGS_DEBUG"), False)
|
|
80
107
|
|
|
@@ -507,6 +534,15 @@ def load_config_from_env() -> LogsInterceptorConfig:
|
|
|
507
534
|
if compression_value not in {"none", "gzip", "brotli", "snappy"}:
|
|
508
535
|
compression_value = "gzip"
|
|
509
536
|
|
|
537
|
+
exporter = _normalize_logs_exporter(
|
|
538
|
+
env.get("LOGS_EXPORTER") or env.get("LOGS_TRANSPORT") or env.get("OTEL_LOGS_EXPORTER")
|
|
539
|
+
)
|
|
540
|
+
headers = {
|
|
541
|
+
**_parse_kv_csv(env.get("LOGS_HEADERS")),
|
|
542
|
+
**_parse_kv_csv(env.get("LOGS_OTLP_HEADERS")),
|
|
543
|
+
**(_parse_kv_csv(env.get("OTEL_EXPORTER_OTLP_HEADERS")) if exporter == "otlp" else {}),
|
|
544
|
+
}
|
|
545
|
+
|
|
510
546
|
dlq_type_raw = (env.get("LOGS_DLQ_TYPE") or "memory").lower()
|
|
511
547
|
dlq_type = dlq_type_raw
|
|
512
548
|
if dlq_type not in {"memory", "file"}:
|
|
@@ -514,9 +550,11 @@ def load_config_from_env() -> LogsInterceptorConfig:
|
|
|
514
550
|
|
|
515
551
|
cfg = LogsInterceptorConfig(
|
|
516
552
|
transport=TransportConfig(
|
|
517
|
-
|
|
553
|
+
exporter=exporter,
|
|
554
|
+
url=_resolve_logs_url(env, exporter),
|
|
518
555
|
tenant_id=env.get("LOGS_TENANT", ""),
|
|
519
556
|
auth_token=env.get("LOGS_TOKEN"),
|
|
557
|
+
headers=headers or None,
|
|
520
558
|
timeout=parse_int_range(env.get("LOGS_TIMEOUT"), 10_000, 0, 600_000),
|
|
521
559
|
max_retries=parse_int_range(env.get("LOGS_MAX_RETRIES"), 3, 0, 20),
|
|
522
560
|
retry_delay=parse_int_range(env.get("LOGS_RETRY_DELAY"), 1_000, 0, 120_000),
|
|
File without changes
|
{elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/ARCHITECTURE.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|