elven-logs-interceptor-python 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. elven_logs_interceptor_python-0.1.2.dist-info/METADATA +262 -0
  2. elven_logs_interceptor_python-0.1.2.dist-info/RECORD +56 -0
  3. elven_logs_interceptor_python-0.1.2.dist-info/WHEEL +4 -0
  4. logs_interceptor/__init__.py +333 -0
  5. logs_interceptor/application/__init__.py +27 -0
  6. logs_interceptor/application/config_service.py +232 -0
  7. logs_interceptor/application/log_service.py +383 -0
  8. logs_interceptor/config.py +190 -0
  9. logs_interceptor/domain/__init__.py +25 -0
  10. logs_interceptor/domain/entities.py +41 -0
  11. logs_interceptor/domain/interfaces.py +149 -0
  12. logs_interceptor/domain/value_objects.py +40 -0
  13. logs_interceptor/infrastructure/__init__.py +48 -0
  14. logs_interceptor/infrastructure/buffer/__init__.py +3 -0
  15. logs_interceptor/infrastructure/buffer/memory_buffer.py +187 -0
  16. logs_interceptor/infrastructure/circuit_breaker/__init__.py +3 -0
  17. logs_interceptor/infrastructure/circuit_breaker/circuit_breaker.py +110 -0
  18. logs_interceptor/infrastructure/compression/__init__.py +14 -0
  19. logs_interceptor/infrastructure/compression/base.py +20 -0
  20. logs_interceptor/infrastructure/compression/brotli_compressor.py +27 -0
  21. logs_interceptor/infrastructure/compression/factory.py +18 -0
  22. logs_interceptor/infrastructure/compression/gzip_compressor.py +20 -0
  23. logs_interceptor/infrastructure/compression/noop_compressor.py +14 -0
  24. logs_interceptor/infrastructure/context/__init__.py +3 -0
  25. logs_interceptor/infrastructure/context/context_provider.py +44 -0
  26. logs_interceptor/infrastructure/dlq/__init__.py +4 -0
  27. logs_interceptor/infrastructure/dlq/file_dlq.py +170 -0
  28. logs_interceptor/infrastructure/dlq/memory_dlq.py +59 -0
  29. logs_interceptor/infrastructure/filter/__init__.py +3 -0
  30. logs_interceptor/infrastructure/filter/log_filter.py +55 -0
  31. logs_interceptor/infrastructure/interceptors/__init__.py +3 -0
  32. logs_interceptor/infrastructure/interceptors/runtime_interceptor.py +139 -0
  33. logs_interceptor/infrastructure/memory/__init__.py +3 -0
  34. logs_interceptor/infrastructure/memory/memory_tracker.py +95 -0
  35. logs_interceptor/infrastructure/metrics/__init__.py +3 -0
  36. logs_interceptor/infrastructure/metrics/metrics_collector.py +104 -0
  37. logs_interceptor/infrastructure/transport/__init__.py +12 -0
  38. logs_interceptor/infrastructure/transport/loki_json_transport.py +226 -0
  39. logs_interceptor/infrastructure/transport/loki_protobuf_transport.py +209 -0
  40. logs_interceptor/infrastructure/transport/resilient_transport.py +161 -0
  41. logs_interceptor/infrastructure/transport/transport_factory.py +39 -0
  42. logs_interceptor/infrastructure/workers/__init__.py +3 -0
  43. logs_interceptor/infrastructure/workers/worker_pool.py +57 -0
  44. logs_interceptor/integrations/__init__.py +17 -0
  45. logs_interceptor/integrations/celery.py +53 -0
  46. logs_interceptor/integrations/django.py +44 -0
  47. logs_interceptor/integrations/fastapi.py +53 -0
  48. logs_interceptor/integrations/flask.py +50 -0
  49. logs_interceptor/integrations/logging_handler.py +43 -0
  50. logs_interceptor/integrations/loguru.py +36 -0
  51. logs_interceptor/integrations/structlog.py +21 -0
  52. logs_interceptor/preload.py +61 -0
  53. logs_interceptor/presentation/__init__.py +3 -0
  54. logs_interceptor/presentation/factory.py +128 -0
  55. logs_interceptor/types.py +89 -0
  56. logs_interceptor/utils.py +508 -0
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ import logging
5
+ import sys
6
+ import traceback as traceback_module
7
+ from types import TracebackType
8
+ from typing import Any
9
+
10
+ from ...domain.interfaces import ILogger
11
+ from ...types import LogLevel
12
+ from ...utils import safe_stringify
13
+
14
+
15
+ class _BridgeLoggingHandler(logging.Handler):
16
+ def __init__(self, logger: ILogger) -> None:
17
+ super().__init__()
18
+ self._logger = logger
19
+
20
+ def emit(self, record: logging.LogRecord) -> None:
21
+ try:
22
+ level = self._map_level(record.levelno)
23
+ msg = record.getMessage()
24
+ context = {
25
+ "source": "logging",
26
+ "logger_name": record.name,
27
+ "module": record.module,
28
+ "function": record.funcName,
29
+ "line": record.lineno,
30
+ }
31
+ if record.exc_info:
32
+ context["exc_info"] = "".join(traceback_module.format_exception(*record.exc_info))
33
+ self._logger.log(level, msg, context)
34
+ except Exception:
35
+ return
36
+
37
+ @staticmethod
38
+ def _map_level(level_no: int) -> LogLevel:
39
+ if level_no >= logging.CRITICAL:
40
+ return "fatal"
41
+ if level_no >= logging.ERROR:
42
+ return "error"
43
+ if level_no >= logging.WARNING:
44
+ return "warn"
45
+ if level_no >= logging.INFO:
46
+ return "info"
47
+ return "debug"
48
+
49
+
50
+ class RuntimeInterceptor:
51
+ def __init__(self, logger: ILogger, preserve_original: bool = True) -> None:
52
+ self._logger = logger
53
+ self._preserve_original = preserve_original
54
+ self._enabled = False
55
+
56
+ self._original_print = builtins.print
57
+ self._original_excepthook = sys.excepthook
58
+ self._root_logger = logging.getLogger()
59
+ self._bridge_handler = _BridgeLoggingHandler(logger)
60
+ self._original_handlers: list[logging.Handler] = []
61
+
62
+ def enable(self) -> None:
63
+ if self._enabled:
64
+ return
65
+ self._enabled = True
66
+
67
+ self._patch_print()
68
+ self._patch_excepthook()
69
+ self._patch_logging()
70
+
71
+ def restore(self) -> None:
72
+ if not self._enabled:
73
+ return
74
+
75
+ builtins.print = self._original_print
76
+ sys.excepthook = self._original_excepthook
77
+
78
+ try:
79
+ self._root_logger.removeHandler(self._bridge_handler)
80
+ except ValueError:
81
+ pass
82
+
83
+ if not self._preserve_original:
84
+ self._root_logger.handlers = self._original_handlers
85
+
86
+ self._enabled = False
87
+
88
+ def is_enabled(self) -> bool:
89
+ return self._enabled
90
+
91
+ def _patch_print(self) -> None:
92
+ def intercepted_print(*args: Any, **kwargs: Any) -> None:
93
+ try:
94
+ message = " ".join(
95
+ [arg if isinstance(arg, str) else safe_stringify(arg) for arg in args]
96
+ )
97
+ self._logger.info(message, {"source": "print"})
98
+ except Exception:
99
+ pass
100
+
101
+ if self._preserve_original:
102
+ self._original_print(*args, **kwargs)
103
+
104
+ builtins.print = intercepted_print
105
+
106
+ def _patch_excepthook(self) -> None:
107
+ def intercepted_excepthook(
108
+ exc_type: type[BaseException],
109
+ exc_value: BaseException,
110
+ traceback: TracebackType | None,
111
+ ) -> None:
112
+ try:
113
+ self._logger.fatal(
114
+ f"Uncaught exception: {exc_value}",
115
+ {
116
+ "source": "sys.excepthook",
117
+ "exception_type": exc_type.__name__,
118
+ "traceback": "".join(traceback_module.format_tb(traceback))
119
+ if traceback
120
+ else None,
121
+ },
122
+ )
123
+ self._logger.flush()
124
+ except Exception:
125
+ pass
126
+
127
+ if self._preserve_original and self._original_excepthook is not None:
128
+ self._original_excepthook(exc_type, exc_value, traceback)
129
+
130
+ sys.excepthook = intercepted_excepthook
131
+
132
+ def _patch_logging(self) -> None:
133
+ if not self._preserve_original:
134
+ self._original_handlers = list(self._root_logger.handlers)
135
+ self._root_logger.handlers = []
136
+
137
+ self._bridge_handler.setLevel(logging.DEBUG)
138
+ self._root_logger.addHandler(self._bridge_handler)
139
+ self._root_logger.setLevel(min(self._root_logger.level, logging.DEBUG) or logging.DEBUG)
@@ -0,0 +1,3 @@
1
+ from .memory_tracker import MemoryStats, MemoryTracker
2
+
3
+ __all__ = ["MemoryTracker", "MemoryStats"]
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ...domain.entities import LogEntryEntity
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class MemoryStats:
10
+ total_bytes: int
11
+ total_mb: float
12
+ entry_count: int
13
+ avg_entry_size: float
14
+
15
+
16
+ class MemoryTracker:
17
+ def __init__(self) -> None:
18
+ self._total_size = 0
19
+ self._entry_sizes: dict[int, int] = {}
20
+ self._entry_count = 0
21
+
22
+ def add_entry(self, entry: LogEntryEntity) -> None:
23
+ size = self._estimate_size(entry)
24
+ self._entry_sizes[id(entry)] = size
25
+ self._total_size += size
26
+ self._entry_count += 1
27
+
28
+ def remove_entry(self, entry: LogEntryEntity) -> None:
29
+ key = id(entry)
30
+ size = self._entry_sizes.pop(key, None)
31
+ if size is None:
32
+ return
33
+ self._total_size -= size
34
+ self._entry_count -= 1
35
+
36
+ def remove_entries(self, entries: list[LogEntryEntity]) -> None:
37
+ for entry in entries:
38
+ self.remove_entry(entry)
39
+
40
+ def get_total_size(self) -> int:
41
+ return self._total_size
42
+
43
+ def get_total_size_mb(self) -> float:
44
+ return self._total_size / 1024 / 1024
45
+
46
+ def get_entry_count(self) -> int:
47
+ return self._entry_count
48
+
49
+ def get_avg_entry_size(self) -> float:
50
+ if self._entry_count <= 0:
51
+ return 0.0
52
+ return self._total_size / self._entry_count
53
+
54
+ def get_stats(self) -> MemoryStats:
55
+ return MemoryStats(
56
+ total_bytes=self._total_size,
57
+ total_mb=self.get_total_size_mb(),
58
+ entry_count=self._entry_count,
59
+ avg_entry_size=self.get_avg_entry_size(),
60
+ )
61
+
62
+ def reset(self) -> None:
63
+ self._total_size = 0
64
+ self._entry_sizes.clear()
65
+ self._entry_count = 0
66
+
67
+ def _estimate_size(self, entry: LogEntryEntity) -> int:
68
+ size = 0
69
+ size += len(entry.id) * 2
70
+ size += len(entry.timestamp) * 2
71
+ size += len(entry.level) * 2
72
+ size += len(entry.message) * 2
73
+
74
+ if entry.trace_id:
75
+ size += len(entry.trace_id) * 2
76
+ if entry.span_id:
77
+ size += len(entry.span_id) * 2
78
+ if entry.request_id:
79
+ size += len(entry.request_id) * 2
80
+
81
+ if entry.context:
82
+ keys = list(entry.context.keys())
83
+ size += len(keys) * 20
84
+ size += len(keys) * 50
85
+
86
+ if entry.labels:
87
+ for key, value in entry.labels.items():
88
+ size += len(key) * 2
89
+ size += len(value) * 2
90
+
91
+ if entry.metadata:
92
+ size += 50
93
+
94
+ size += 200
95
+ return size
@@ -0,0 +1,3 @@
1
+ from .metrics_collector import MetricsCollector
2
+
3
+ __all__ = ["MetricsCollector"]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from statistics import mean
4
+ from time import time
5
+
6
+
7
+ class MetricsCollector:
8
+ def __init__(self, max_samples: int = 10_000) -> None:
9
+ self._latencies: list[float] = []
10
+ self._compression_ratios: list[float] = []
11
+ self._compression_times: list[float] = []
12
+ self._operation_timestamps: list[float] = []
13
+ self._total_original_bytes = 0
14
+ self._total_compressed_bytes = 0
15
+ self._max_samples = max_samples
16
+
17
+ def record_latency(self, ms: float) -> None:
18
+ self._latencies.append(ms)
19
+ self._operation_timestamps.append(time())
20
+
21
+ if len(self._latencies) > self._max_samples:
22
+ self._latencies.pop(0)
23
+ if len(self._operation_timestamps) > self._max_samples:
24
+ self._operation_timestamps.pop(0)
25
+
26
+ def record_compression(self, original_size: int, compressed_size: int, time_ms: float) -> None:
27
+ self._total_original_bytes += original_size
28
+ self._total_compressed_bytes += compressed_size
29
+
30
+ if original_size > 0:
31
+ ratio = (1 - compressed_size / original_size) * 100
32
+ self._compression_ratios.append(ratio)
33
+ if len(self._compression_ratios) > self._max_samples:
34
+ self._compression_ratios.pop(0)
35
+
36
+ self._compression_times.append(time_ms)
37
+ if len(self._compression_times) > self._max_samples:
38
+ self._compression_times.pop(0)
39
+
40
+ def get_latency_metrics(self) -> dict[str, float]:
41
+ if not self._latencies:
42
+ return {
43
+ "p50": 0.0,
44
+ "p95": 0.0,
45
+ "p99": 0.0,
46
+ "p999": 0.0,
47
+ "min": 0.0,
48
+ "max": 0.0,
49
+ "avg": 0.0,
50
+ "count": 0.0,
51
+ }
52
+
53
+ sorted_values = sorted(self._latencies)
54
+ count = len(sorted_values)
55
+
56
+ return {
57
+ "p50": self._get_percentile(sorted_values, 50),
58
+ "p95": self._get_percentile(sorted_values, 95),
59
+ "p99": self._get_percentile(sorted_values, 99),
60
+ "p999": self._get_percentile(sorted_values, 99.9),
61
+ "min": sorted_values[0],
62
+ "max": sorted_values[-1],
63
+ "avg": mean(sorted_values),
64
+ "count": float(count),
65
+ }
66
+
67
+ def get_compression_metrics(self) -> dict[str, float | int]:
68
+ avg_ratio = mean(self._compression_ratios) if self._compression_ratios else 0.0
69
+ avg_time = mean(self._compression_times) if self._compression_times else 0.0
70
+
71
+ return {
72
+ "avg_ratio": avg_ratio,
73
+ "avg_time": avg_time,
74
+ "total_original_bytes": self._total_original_bytes,
75
+ "total_compressed_bytes": self._total_compressed_bytes,
76
+ "total_saved_bytes": self._total_original_bytes - self._total_compressed_bytes,
77
+ "count": len(self._compression_ratios),
78
+ }
79
+
80
+ def get_throughput(self, window_seconds: int = 60) -> float:
81
+ if window_seconds <= 0:
82
+ return 0.0
83
+ if not self._operation_timestamps:
84
+ return 0.0
85
+
86
+ now = time()
87
+ window_start = now - window_seconds
88
+ in_window = len([ts for ts in self._operation_timestamps if ts >= window_start])
89
+ return in_window / window_seconds
90
+
91
+ def reset(self) -> None:
92
+ self._latencies.clear()
93
+ self._compression_ratios.clear()
94
+ self._compression_times.clear()
95
+ self._operation_timestamps.clear()
96
+ self._total_original_bytes = 0
97
+ self._total_compressed_bytes = 0
98
+
99
+ def _get_percentile(self, sorted_values: list[float], percentile: float) -> float:
100
+ if not sorted_values:
101
+ return 0.0
102
+ index = int((percentile / 100) * len(sorted_values))
103
+ bounded = min(max(index, 1), len(sorted_values)) - 1
104
+ return sorted_values[bounded]
@@ -0,0 +1,12 @@
1
+ from .loki_json_transport import LokiJsonTransport
2
+ from .loki_protobuf_transport import LokiProtobufTransport
3
+ from .resilient_transport import ResilientTransport, ResilientTransportConfig
4
+ from .transport_factory import TransportFactory
5
+
6
+ __all__ = [
7
+ "LokiJsonTransport",
8
+ "LokiProtobufTransport",
9
+ "ResilientTransport",
10
+ "ResilientTransportConfig",
11
+ "TransportFactory",
12
+ ]
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from typing import Any, cast
9
+
10
+ import httpx
11
+
12
+ from ...config import ResolvedTransportConfig
13
+ from ...domain.entities import LogEntryEntity
14
+ from ...types import TransportHealth, TransportMetrics
15
+ from ..compression import CompressorConfig, CompressorFactory
16
+
17
+ try:
18
+ import orjson # type: ignore[import-not-found]
19
+ except Exception: # pragma: no cover - optional dependency
20
+ orjson = None
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class RetryableTransportError(Exception):
25
+ message: str
26
+ status_code: int | None = None
27
+ retryable: bool = False
28
+
29
+ def __str__(self) -> str:
30
+ return self.message
31
+
32
+
33
+ class LokiJsonTransport:
34
+ def __init__(
35
+ self,
36
+ config: ResolvedTransportConfig,
37
+ extra_headers: dict[str, str] | None = None,
38
+ ) -> None:
39
+ self._config = config
40
+ self._extra_headers = extra_headers or {}
41
+ self._timeout = config.timeout / 1000
42
+ self._compression_threshold = config.compression_threshold
43
+ self._compressor = CompressorFactory.create(
44
+ config.compression,
45
+ CompressorConfig(level=config.compression_level, threshold=config.compression_threshold),
46
+ )
47
+ limits = httpx.Limits(max_keepalive_connections=config.max_sockets, max_connections=config.max_sockets)
48
+ self._client: httpx.Client | None = None
49
+ if config.enable_connection_pooling:
50
+ self._client = httpx.Client(timeout=self._timeout, limits=limits)
51
+
52
+ self._health: TransportHealth = {
53
+ "healthy": True,
54
+ "consecutive_failures": 0,
55
+ }
56
+ self._metrics: TransportMetrics = {
57
+ "total_sends": 0,
58
+ "successful_sends": 0,
59
+ "failed_sends": 0,
60
+ "avg_latency": 0.0,
61
+ "avg_compression_time": 0.0,
62
+ "avg_compression_ratio": 0.0,
63
+ "total_bytes_sent": 0,
64
+ "total_bytes_compressed": 0,
65
+ }
66
+
67
+ def send(self, entries: list[LogEntryEntity]) -> None:
68
+ if not entries:
69
+ return
70
+
71
+ start = time.perf_counter()
72
+ self._metrics["total_sends"] = self._metrics.get("total_sends", 0) + 1
73
+
74
+ try:
75
+ payload = self._format_for_loki(entries)
76
+ raw_bytes = self._dumps(payload)
77
+ raw_size = len(raw_bytes)
78
+
79
+ body = raw_bytes
80
+ compression_time = 0.0
81
+ was_compressed = False
82
+ if self._compressor.get_name() != "none" and raw_size >= self._compression_threshold:
83
+ compression_start = time.perf_counter()
84
+ body = self._compressor.compress(raw_bytes)
85
+ compression_time = (time.perf_counter() - compression_start) * 1000
86
+ was_compressed = True
87
+
88
+ headers: dict[str, str] = {
89
+ "Content-Type": "application/json",
90
+ "X-Scope-OrgID": self._config.tenant_id,
91
+ "User-Agent": "elven-logs-interceptor-python/0.1.2",
92
+ **self._extra_headers,
93
+ }
94
+ if self._config.auth_token:
95
+ headers["Authorization"] = f"Bearer {self._config.auth_token}"
96
+ if was_compressed:
97
+ encoding = self._compressor.get_content_encoding()
98
+ if encoding:
99
+ headers["Content-Encoding"] = encoding
100
+
101
+ response = self._request(headers, body)
102
+
103
+ if response.status_code >= 300:
104
+ raise RetryableTransportError(
105
+ message=f"Loki responded with {response.status_code}: {response.text}",
106
+ status_code=response.status_code,
107
+ retryable=response.status_code == 429 or response.status_code >= 500,
108
+ )
109
+
110
+ latency = (time.perf_counter() - start) * 1000
111
+ self._record_success(latency)
112
+ self._metrics["total_bytes_sent"] = self._metrics.get("total_bytes_sent", 0) + len(body)
113
+ self._metrics["total_bytes_compressed"] = self._metrics.get("total_bytes_compressed", 0) + raw_size
114
+
115
+ if was_compressed:
116
+ self._update_compression_metrics(compression_time, raw_size, len(body))
117
+ except Exception as exc:
118
+ self._record_failure(exc)
119
+ raise
120
+
121
+ def _request(self, headers: dict[str, str], body: bytes) -> httpx.Response:
122
+ if self._client is not None:
123
+ return self._client.post(self._config.url, headers=headers, content=body)
124
+
125
+ with httpx.Client(timeout=self._timeout) as client:
126
+ return client.post(self._config.url, headers=headers, content=body)
127
+
128
+ def is_available(self) -> bool:
129
+ return bool(self._health.get("healthy", False))
130
+
131
+ def get_health(self) -> TransportHealth:
132
+ return cast(TransportHealth, dict(self._health))
133
+
134
+ def get_metrics(self) -> TransportMetrics:
135
+ return cast(TransportMetrics, dict(self._metrics))
136
+
137
+ def destroy(self) -> None:
138
+ if self._client is not None:
139
+ self._client.close()
140
+ self._client = None
141
+
142
+ def _record_success(self, duration_ms: float) -> None:
143
+ self._health = {
144
+ "healthy": True,
145
+ "consecutive_failures": 0,
146
+ "last_successful_send": time.time(),
147
+ }
148
+ successful = self._metrics.get("successful_sends", 0) + 1
149
+ self._metrics["successful_sends"] = successful
150
+ current_avg = float(self._metrics.get("avg_latency", 0.0))
151
+ self._metrics["avg_latency"] = ((current_avg * (successful - 1)) + duration_ms) / successful
152
+
153
+ def _record_failure(self, error: Exception) -> None:
154
+ failures = int(self._health.get("consecutive_failures", 0)) + 1
155
+ self._health = {
156
+ "healthy": False,
157
+ "consecutive_failures": failures,
158
+ "error_message": str(error),
159
+ }
160
+ self._metrics["failed_sends"] = self._metrics.get("failed_sends", 0) + 1
161
+
162
+ def _update_compression_metrics(self, duration_ms: float, raw_size: int, compressed_size: int) -> None:
163
+ successful = max(1, self._metrics.get("successful_sends", 1))
164
+ current_time = float(self._metrics.get("avg_compression_time", 0.0))
165
+ self._metrics["avg_compression_time"] = (
166
+ (current_time * (successful - 1)) + duration_ms
167
+ ) / successful
168
+
169
+ ratio = compressed_size / raw_size if raw_size > 0 else 1.0
170
+ current_ratio = float(self._metrics.get("avg_compression_ratio", 0.0))
171
+ self._metrics["avg_compression_ratio"] = (
172
+ (current_ratio * (successful - 1)) + ratio
173
+ ) / successful
174
+
175
+ def _format_for_loki(self, entries: list[LogEntryEntity]) -> dict[str, Any]:
176
+ streams: dict[str, dict[str, Any]] = {}
177
+ values_map: dict[str, list[list[str]]] = defaultdict(list)
178
+
179
+ for entry in entries:
180
+ labels = entry.labels or {}
181
+ key = json.dumps(labels, sort_keys=True)
182
+
183
+ payload: dict[str, Any] = {
184
+ "id": entry.id,
185
+ "level": entry.level,
186
+ "message": entry.message,
187
+ "context": entry.context,
188
+ }
189
+ if entry.trace_id:
190
+ payload["traceId"] = entry.trace_id
191
+ if entry.span_id:
192
+ payload["spanId"] = entry.span_id
193
+ if entry.request_id:
194
+ payload["requestId"] = entry.request_id
195
+ if entry.metadata:
196
+ payload["metadata"] = entry.metadata
197
+
198
+ timestamp_ns = self._timestamp_to_ns(entry.timestamp)
199
+ values_map[key].append([str(timestamp_ns), self._dumps(payload).decode("utf-8")])
200
+ streams[key] = labels
201
+
202
+ formatted_streams: list[dict[str, Any]] = []
203
+ for key, stream_labels in streams.items():
204
+ values = values_map[key]
205
+ values.sort(key=lambda item: item[0])
206
+ formatted_streams.append({"stream": stream_labels, "values": values})
207
+
208
+ return {"streams": formatted_streams}
209
+
210
+ @staticmethod
211
+ def _timestamp_to_ns(iso_timestamp: str) -> int:
212
+ try:
213
+ normalized = iso_timestamp.replace("Z", "+00:00")
214
+ dt_obj = datetime.fromisoformat(normalized)
215
+ if dt_obj.tzinfo is None:
216
+ dt_obj = dt_obj.replace(tzinfo=timezone.utc)
217
+ seconds = int(dt_obj.timestamp())
218
+ return (seconds * 1_000_000_000) + (dt_obj.microsecond * 1000)
219
+ except Exception:
220
+ return int(time.time() * 1_000_000_000)
221
+
222
+ @staticmethod
223
+ def _dumps(payload: Any) -> bytes:
224
+ if orjson is not None:
225
+ return cast(bytes, orjson.dumps(payload))
226
+ return json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")