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.
Files changed (62) hide show
  1. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/PKG-INFO +13 -2
  2. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/README.md +12 -1
  3. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/pyproject.toml +1 -1
  4. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/__init__.py +2 -0
  5. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/application/config_service.py +12 -1
  6. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/config.py +5 -0
  7. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/__init__.py +2 -0
  8. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/transport/__init__.py +2 -0
  9. elven_logs_interceptor_python-0.1.12/src/logs_interceptor/infrastructure/transport/otlp_json_transport.py +260 -0
  10. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/transport/transport_factory.py +5 -1
  11. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/utils.py +39 -1
  12. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/.gitignore +0 -0
  13. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/ARCHITECTURE.md +0 -0
  14. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/application/__init__.py +0 -0
  15. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/application/log_service.py +0 -0
  16. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/domain/__init__.py +0 -0
  17. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/domain/entities.py +0 -0
  18. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/domain/interfaces.py +0 -0
  19. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/domain/value_objects.py +0 -0
  20. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/buffer/__init__.py +0 -0
  21. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/buffer/memory_buffer.py +0 -0
  22. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/circuit_breaker/__init__.py +0 -0
  23. {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
  24. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/__init__.py +0 -0
  25. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/base.py +0 -0
  26. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/brotli_compressor.py +0 -0
  27. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/factory.py +0 -0
  28. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/gzip_compressor.py +0 -0
  29. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/compression/noop_compressor.py +0 -0
  30. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/context/__init__.py +0 -0
  31. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/context/context_provider.py +0 -0
  32. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/dlq/__init__.py +0 -0
  33. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/dlq/file_dlq.py +0 -0
  34. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/dlq/memory_dlq.py +0 -0
  35. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/filter/__init__.py +0 -0
  36. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/filter/log_filter.py +0 -0
  37. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/interceptors/__init__.py +0 -0
  38. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/interceptors/runtime_interceptor.py +0 -0
  39. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/internal_capture_guard.py +0 -0
  40. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/log_noise_filter.py +0 -0
  41. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/log_record_extra.py +0 -0
  42. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/memory/__init__.py +0 -0
  43. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/memory/memory_tracker.py +0 -0
  44. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/metrics/__init__.py +0 -0
  45. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/metrics/metrics_collector.py +0 -0
  46. {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
  47. {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
  48. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/transport/resilient_transport.py +0 -0
  49. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/workers/__init__.py +0 -0
  50. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/infrastructure/workers/worker_pool.py +0 -0
  51. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/__init__.py +0 -0
  52. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/celery.py +0 -0
  53. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/django.py +0 -0
  54. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/fastapi.py +0 -0
  55. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/flask.py +0 -0
  56. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/logging_handler.py +0 -0
  57. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/loguru.py +0 -0
  58. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/integrations/structlog.py +0 -0
  59. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/preload.py +0 -0
  60. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/presentation/__init__.py +0 -0
  61. {elven_logs_interceptor_python-0.1.10 → elven_logs_interceptor_python-0.1.12}/src/logs_interceptor/presentation/factory.py +0 -0
  62. {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.10
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`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "elven-logs-interceptor-python"
7
- version = "0.1.10"
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.compression == "snappy":
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
- url=env.get("LOGS_URL", ""),
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),