elven-logs-interceptor-python 0.1.9__tar.gz → 0.1.11__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 (91) hide show
  1. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/PKG-INFO +15 -6
  2. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/README.md +9 -0
  3. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/pyproject.toml +11 -11
  4. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/__init__.py +2 -0
  5. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/application/config_service.py +12 -1
  6. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/config.py +5 -0
  7. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/__init__.py +2 -0
  8. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/transport/__init__.py +2 -0
  9. elven_logs_interceptor_python-0.1.11/src/logs_interceptor/infrastructure/transport/otlp_json_transport.py +260 -0
  10. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/transport/transport_factory.py +5 -1
  11. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/utils.py +55 -1
  12. elven_logs_interceptor_python-0.1.9/examples/basic_app.py +0 -18
  13. elven_logs_interceptor_python-0.1.9/examples/fastapi_integration.py +0 -26
  14. elven_logs_interceptor_python-0.1.9/examples/full_config_reference.py +0 -67
  15. elven_logs_interceptor_python-0.1.9/examples/high_volume.py +0 -30
  16. elven_logs_interceptor_python-0.1.9/examples/tracking_usage.py +0 -14
  17. elven_logs_interceptor_python-0.1.9/scripts/publish.sh +0 -180
  18. elven_logs_interceptor_python-0.1.9/test-apps/elven-live-demo/.env.example +0 -49
  19. elven_logs_interceptor_python-0.1.9/test-apps/elven-live-demo/README.md +0 -17
  20. elven_logs_interceptor_python-0.1.9/test-apps/elven-live-demo/app.py +0 -97
  21. elven_logs_interceptor_python-0.1.9/test-apps/elven-live-demo/run.sh +0 -21
  22. elven_logs_interceptor_python-0.1.9/test-apps/elven-observability-smoke/.env.example +0 -167
  23. elven_logs_interceptor_python-0.1.9/test-apps/elven-observability-smoke/README.md +0 -27
  24. elven_logs_interceptor_python-0.1.9/test-apps/elven-observability-smoke/app.py +0 -90
  25. elven_logs_interceptor_python-0.1.9/test-apps/elven-observability-smoke/run.sh +0 -21
  26. elven_logs_interceptor_python-0.1.9/tests/integration/test_api.py +0 -122
  27. elven_logs_interceptor_python-0.1.9/tests/unit/test_circuit_breaker_extra.py +0 -48
  28. elven_logs_interceptor_python-0.1.9/tests/unit/test_config_service.py +0 -144
  29. elven_logs_interceptor_python-0.1.9/tests/unit/test_core_components.py +0 -77
  30. elven_logs_interceptor_python-0.1.9/tests/unit/test_env_config.py +0 -59
  31. elven_logs_interceptor_python-0.1.9/tests/unit/test_integration_filters.py +0 -119
  32. elven_logs_interceptor_python-0.1.9/tests/unit/test_log_filter_extra.py +0 -68
  33. elven_logs_interceptor_python-0.1.9/tests/unit/test_log_service_unit.py +0 -578
  34. elven_logs_interceptor_python-0.1.9/tests/unit/test_loguru_sink.py +0 -137
  35. elven_logs_interceptor_python-0.1.9/tests/unit/test_loki_json_transport.py +0 -179
  36. elven_logs_interceptor_python-0.1.9/tests/unit/test_memory_buffer_extra.py +0 -145
  37. elven_logs_interceptor_python-0.1.9/tests/unit/test_protobuf_transport_safety.py +0 -141
  38. elven_logs_interceptor_python-0.1.9/tests/unit/test_resilient_transport.py +0 -211
  39. elven_logs_interceptor_python-0.1.9/tests/unit/test_runtime_interceptor.py +0 -88
  40. elven_logs_interceptor_python-0.1.9/tests/unit/test_utils_extra.py +0 -276
  41. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/.gitignore +0 -0
  42. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/ARCHITECTURE.md +0 -0
  43. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/application/__init__.py +0 -0
  44. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/application/log_service.py +0 -0
  45. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/domain/__init__.py +0 -0
  46. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/domain/entities.py +0 -0
  47. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/domain/interfaces.py +0 -0
  48. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/domain/value_objects.py +0 -0
  49. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/buffer/__init__.py +0 -0
  50. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/buffer/memory_buffer.py +0 -0
  51. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/circuit_breaker/__init__.py +0 -0
  52. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/circuit_breaker/circuit_breaker.py +0 -0
  53. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/compression/__init__.py +0 -0
  54. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/compression/base.py +0 -0
  55. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/compression/brotli_compressor.py +0 -0
  56. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/compression/factory.py +0 -0
  57. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/compression/gzip_compressor.py +0 -0
  58. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/compression/noop_compressor.py +0 -0
  59. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/context/__init__.py +0 -0
  60. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/context/context_provider.py +0 -0
  61. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/dlq/__init__.py +0 -0
  62. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/dlq/file_dlq.py +0 -0
  63. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/dlq/memory_dlq.py +0 -0
  64. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/filter/__init__.py +0 -0
  65. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/filter/log_filter.py +0 -0
  66. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/interceptors/__init__.py +0 -0
  67. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/interceptors/runtime_interceptor.py +0 -0
  68. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/internal_capture_guard.py +0 -0
  69. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/log_noise_filter.py +0 -0
  70. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/log_record_extra.py +0 -0
  71. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/memory/__init__.py +0 -0
  72. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/memory/memory_tracker.py +0 -0
  73. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/metrics/__init__.py +0 -0
  74. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/metrics/metrics_collector.py +0 -0
  75. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/transport/loki_json_transport.py +0 -0
  76. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/transport/loki_protobuf_transport.py +0 -0
  77. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/transport/resilient_transport.py +0 -0
  78. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/workers/__init__.py +0 -0
  79. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/infrastructure/workers/worker_pool.py +0 -0
  80. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/integrations/__init__.py +0 -0
  81. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/integrations/celery.py +0 -0
  82. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/integrations/django.py +0 -0
  83. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/integrations/fastapi.py +0 -0
  84. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/integrations/flask.py +0 -0
  85. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/integrations/logging_handler.py +0 -0
  86. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/integrations/loguru.py +0 -0
  87. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/integrations/structlog.py +0 -0
  88. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/preload.py +0 -0
  89. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/presentation/__init__.py +0 -0
  90. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/src/logs_interceptor/presentation/factory.py +0 -0
  91. {elven_logs_interceptor_python-0.1.9 → elven_logs_interceptor_python-0.1.11}/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.9
3
+ Version: 0.1.11
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
@@ -14,20 +14,20 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Topic :: System :: Logging
16
16
  Requires-Python: >=3.10
17
- Requires-Dist: httpx>=0.27.0
17
+ Requires-Dist: httpx<0.29.0,>=0.24.1
18
18
  Requires-Dist: typing-extensions>=4.12.0
19
19
  Provides-Extra: all
20
20
  Requires-Dist: brotli>=1.1.0; extra == 'all'
21
21
  Requires-Dist: celery>=5.4.0; extra == 'all'
22
22
  Requires-Dist: django>=4.2; extra == 'all'
23
- Requires-Dist: fastapi>=0.111.0; extra == 'all'
23
+ Requires-Dist: fastapi>=0.100.0; extra == 'all'
24
24
  Requires-Dist: flask>=3.0.0; extra == 'all'
25
25
  Requires-Dist: loguru>=0.7.2; extra == 'all'
26
26
  Requires-Dist: opentelemetry-api>=1.24.0; extra == 'all'
27
27
  Requires-Dist: orjson>=3.10.0; extra == 'all'
28
28
  Requires-Dist: protobuf>=5.0.0; extra == 'all'
29
29
  Requires-Dist: python-snappy>=0.7.1; extra == 'all'
30
- Requires-Dist: starlette>=0.37.0; extra == 'all'
30
+ Requires-Dist: starlette>=0.27.0; extra == 'all'
31
31
  Requires-Dist: structlog>=24.0.0; extra == 'all'
32
32
  Provides-Extra: celery
33
33
  Requires-Dist: celery>=5.4.0; extra == 'celery'
@@ -42,8 +42,8 @@ Requires-Dist: twine>=5.1.1; extra == 'dev'
42
42
  Provides-Extra: django
43
43
  Requires-Dist: django>=4.2; extra == 'django'
44
44
  Provides-Extra: fastapi
45
- Requires-Dist: fastapi>=0.111.0; extra == 'fastapi'
46
- Requires-Dist: starlette>=0.37.0; extra == 'fastapi'
45
+ Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
46
+ Requires-Dist: starlette>=0.27.0; extra == 'fastapi'
47
47
  Provides-Extra: flask
48
48
  Requires-Dist: flask>=3.0.0; extra == 'flask'
49
49
  Provides-Extra: loguru
@@ -138,6 +138,12 @@ Required:
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_ENDPOINT` or `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`. In this
143
+ mode `LOGS_TENANT` and `LOGS_TOKEN` are not required by the library; put tenant
144
+ or auth headers in `OTEL_EXPORTER_OTLP_HEADERS`/`LOGS_OTLP_HEADERS` if your
145
+ collector gateway requires them.
146
+
141
147
  Core:
142
148
 
143
149
  - `LOGS_TOKEN`
@@ -146,6 +152,9 @@ Core:
146
152
 
147
153
  Transport:
148
154
 
155
+ - `LOGS_EXPORTER` (`loki|otlp`, aliases: `collector`, `otel`)
156
+ - `LOGS_OTLP_ENDPOINT` (explicit OTLP logs endpoint, e.g. `http://collector:4318/v1/logs`)
157
+ - `LOGS_OTLP_HEADERS` (comma-separated headers, e.g. `x-tenant=team-a`)
149
158
  - `LOGS_COMPRESSION` (`none|gzip|brotli|snappy`)
150
159
  - `LOGS_COMPRESSION_LEVEL`
151
160
  - `LOGS_COMPRESSION_THRESHOLD`
@@ -77,6 +77,12 @@ Required:
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_ENDPOINT` or `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`. In this
82
+ mode `LOGS_TENANT` and `LOGS_TOKEN` are not required by the library; put tenant
83
+ or auth headers in `OTEL_EXPORTER_OTLP_HEADERS`/`LOGS_OTLP_HEADERS` if your
84
+ collector gateway requires them.
85
+
80
86
  Core:
81
87
 
82
88
  - `LOGS_TOKEN`
@@ -85,6 +91,9 @@ Core:
85
91
 
86
92
  Transport:
87
93
 
94
+ - `LOGS_EXPORTER` (`loki|otlp`, aliases: `collector`, `otel`)
95
+ - `LOGS_OTLP_ENDPOINT` (explicit OTLP logs endpoint, e.g. `http://collector:4318/v1/logs`)
96
+ - `LOGS_OTLP_HEADERS` (comma-separated headers, e.g. `x-tenant=team-a`)
88
97
  - `LOGS_COMPRESSION` (`none|gzip|brotli|snappy`)
89
98
  - `LOGS_COMPRESSION_LEVEL`
90
99
  - `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.9"
7
+ version = "0.1.11"
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"
@@ -32,7 +32,7 @@ classifiers = [
32
32
  "Topic :: System :: Logging",
33
33
  ]
34
34
  dependencies = [
35
- "httpx>=0.27.0",
35
+ "httpx>=0.24.1,<0.29.0",
36
36
  "typing-extensions>=4.12.0",
37
37
  ]
38
38
 
@@ -47,8 +47,8 @@ otel = [
47
47
  "opentelemetry-api>=1.24.0",
48
48
  ]
49
49
  fastapi = [
50
- "fastapi>=0.111.0",
51
- "starlette>=0.37.0",
50
+ "fastapi>=0.100.0",
51
+ "starlette>=0.27.0",
52
52
  ]
53
53
  django = [
54
54
  "django>=4.2",
@@ -71,8 +71,8 @@ all = [
71
71
  "python-snappy>=0.7.1",
72
72
  "brotli>=1.1.0",
73
73
  "opentelemetry-api>=1.24.0",
74
- "fastapi>=0.111.0",
75
- "starlette>=0.37.0",
74
+ "fastapi>=0.100.0",
75
+ "starlette>=0.27.0",
76
76
  "django>=4.2",
77
77
  "flask>=3.0.0",
78
78
  "celery>=5.4.0",
@@ -93,22 +93,22 @@ dev = [
93
93
  packages = ["src/logs_interceptor"]
94
94
 
95
95
  [tool.hatch.build.targets.sdist]
96
- include = [
96
+ only-include = [
97
97
  "/ARCHITECTURE.md",
98
98
  "/README.md",
99
- "/examples",
100
99
  "/pyproject.toml",
101
- "/scripts",
102
100
  "/src",
103
- "/test-apps",
104
- "/tests",
105
101
  ]
106
102
  exclude = [
103
+ "/.mypy_cache",
104
+ "/.pytest_cache",
105
+ "/.ruff_cache",
107
106
  "/.venv*",
108
107
  "/.logs-dlq",
109
108
  "/build",
110
109
  "/collector",
111
110
  "/dist",
111
+ "/test-apps",
112
112
  ]
113
113
 
114
114
  [tool.pytest.ini_options]
@@ -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,49 @@ 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 _otlp_logs_endpoint_from_base(endpoint: str | None) -> str:
100
+ if not endpoint:
101
+ return ""
102
+ normalized = endpoint.rstrip("/")
103
+ if normalized.endswith("/v1/logs"):
104
+ return normalized
105
+ if normalized.endswith("/v1/traces") or normalized.endswith("/v1/metrics"):
106
+ return normalized.rsplit("/", 1)[0] + "/logs"
107
+ return normalized + "/v1/logs"
108
+
109
+
110
+ def _resolve_logs_url(env: Mapping[str, str], exporter: str) -> str:
111
+ if exporter != "otlp":
112
+ return env.get("LOGS_URL", "")
113
+ return (
114
+ env.get("LOGS_URL")
115
+ or env.get("LOGS_OTLP_ENDPOINT")
116
+ or env.get("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT")
117
+ or _otlp_logs_endpoint_from_base(env.get("OTEL_EXPORTER_OTLP_ENDPOINT"))
118
+ )
119
+
120
+
78
121
  def is_debug_enabled() -> bool:
79
122
  return parse_bool(os.getenv("LOGS_DEBUG"), False)
80
123
 
@@ -507,6 +550,15 @@ def load_config_from_env() -> LogsInterceptorConfig:
507
550
  if compression_value not in {"none", "gzip", "brotli", "snappy"}:
508
551
  compression_value = "gzip"
509
552
 
553
+ exporter = _normalize_logs_exporter(
554
+ env.get("LOGS_EXPORTER") or env.get("LOGS_TRANSPORT") or env.get("OTEL_LOGS_EXPORTER")
555
+ )
556
+ headers = {
557
+ **_parse_kv_csv(env.get("LOGS_HEADERS")),
558
+ **_parse_kv_csv(env.get("LOGS_OTLP_HEADERS")),
559
+ **(_parse_kv_csv(env.get("OTEL_EXPORTER_OTLP_HEADERS")) if exporter == "otlp" else {}),
560
+ }
561
+
510
562
  dlq_type_raw = (env.get("LOGS_DLQ_TYPE") or "memory").lower()
511
563
  dlq_type = dlq_type_raw
512
564
  if dlq_type not in {"memory", "file"}:
@@ -514,9 +566,11 @@ def load_config_from_env() -> LogsInterceptorConfig:
514
566
 
515
567
  cfg = LogsInterceptorConfig(
516
568
  transport=TransportConfig(
517
- url=env.get("LOGS_URL", ""),
569
+ exporter=exporter,
570
+ url=_resolve_logs_url(env, exporter),
518
571
  tenant_id=env.get("LOGS_TENANT", ""),
519
572
  auth_token=env.get("LOGS_TOKEN"),
573
+ headers=headers or None,
520
574
  timeout=parse_int_range(env.get("LOGS_TIMEOUT"), 10_000, 0, 600_000),
521
575
  max_retries=parse_int_range(env.get("LOGS_MAX_RETRIES"), 3, 0, 20),
522
576
  retry_delay=parse_int_range(env.get("LOGS_RETRY_DELAY"), 1_000, 0, 120_000),
@@ -1,18 +0,0 @@
1
- from logs_interceptor import init
2
-
3
- init(
4
- {
5
- "appName": "my-service",
6
- "interceptConsole": True,
7
- "transport": {
8
- "url": "http://localhost:3100/loki/api/v1/push",
9
- "tenantId": "my-tenant",
10
- "compression": "gzip",
11
- "enableConnectionPooling": True,
12
- },
13
- "deadLetterQueue": {"enabled": True, "type": "file"},
14
- "circuitBreaker": {"enabled": True},
15
- }
16
- )
17
-
18
- print("service started")
@@ -1,26 +0,0 @@
1
- from fastapi import FastAPI
2
-
3
- from logs_interceptor import init, logger
4
- from logs_interceptor.integrations import FastAPIMiddleware
5
-
6
- init(
7
- {
8
- "transport": {
9
- "url": "http://localhost:3100/loki/api/v1/push",
10
- "tenantId": "my-tenant",
11
- "compression": "brotli",
12
- },
13
- "appName": "my-api",
14
- "interceptConsole": True,
15
- "deadLetterQueue": {"enabled": True, "type": "file"},
16
- }
17
- )
18
-
19
- app = FastAPI()
20
- app.add_middleware(FastAPIMiddleware, logger=logger)
21
-
22
-
23
- @app.get("/ping")
24
- def ping() -> dict[str, str]:
25
- logger.info("ping called")
26
- return {"message": "pong"}