iii-observability 0.13.0.dev1__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.
- iii_observability/__init__.py +58 -0
- iii_observability/baggage_span_processor.py +42 -0
- iii_observability/http_instrumentation.py +102 -0
- iii_observability/logger.py +184 -0
- iii_observability/payload.py +92 -0
- iii_observability/py.typed +0 -0
- iii_observability/reconnection.py +24 -0
- iii_observability/span_ops.py +38 -0
- iii_observability/telemetry.py +592 -0
- iii_observability/telemetry_exporters.py +457 -0
- iii_observability/telemetry_types.py +46 -0
- iii_observability-0.13.0.dev1.dist-info/METADATA +34 -0
- iii_observability-0.13.0.dev1.dist-info/RECORD +14 -0
- iii_observability-0.13.0.dev1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""iii-observability: shared OTel + Logger primitives."""
|
|
2
|
+
|
|
3
|
+
from .baggage_span_processor import DEFAULT_ALLOWLIST, BaggageSpanProcessor
|
|
4
|
+
from .http_instrumentation import execute_traced_request
|
|
5
|
+
from .logger import Logger
|
|
6
|
+
from .payload import (
|
|
7
|
+
REDACTED_PLACEHOLDER,
|
|
8
|
+
redact,
|
|
9
|
+
redact_and_truncate,
|
|
10
|
+
resolve_max_bytes_from_env,
|
|
11
|
+
)
|
|
12
|
+
from .reconnection import ReconnectionConfig
|
|
13
|
+
from .span_ops import (
|
|
14
|
+
current_span_is_recording,
|
|
15
|
+
record_span_event,
|
|
16
|
+
set_current_span_attribute,
|
|
17
|
+
set_current_span_error,
|
|
18
|
+
)
|
|
19
|
+
from .telemetry import (
|
|
20
|
+
current_span_id,
|
|
21
|
+
current_trace_id,
|
|
22
|
+
extract_baggage,
|
|
23
|
+
extract_traceparent,
|
|
24
|
+
flush_otel,
|
|
25
|
+
init_otel,
|
|
26
|
+
inject_baggage,
|
|
27
|
+
inject_traceparent,
|
|
28
|
+
shutdown_otel,
|
|
29
|
+
with_span,
|
|
30
|
+
)
|
|
31
|
+
from .telemetry_types import OtelConfig
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BaggageSpanProcessor",
|
|
35
|
+
"DEFAULT_ALLOWLIST",
|
|
36
|
+
"Logger",
|
|
37
|
+
"OtelConfig",
|
|
38
|
+
"REDACTED_PLACEHOLDER",
|
|
39
|
+
"ReconnectionConfig",
|
|
40
|
+
"current_span_id",
|
|
41
|
+
"current_span_is_recording",
|
|
42
|
+
"current_trace_id",
|
|
43
|
+
"execute_traced_request",
|
|
44
|
+
"extract_baggage",
|
|
45
|
+
"extract_traceparent",
|
|
46
|
+
"flush_otel",
|
|
47
|
+
"init_otel",
|
|
48
|
+
"inject_baggage",
|
|
49
|
+
"inject_traceparent",
|
|
50
|
+
"record_span_event",
|
|
51
|
+
"redact",
|
|
52
|
+
"redact_and_truncate",
|
|
53
|
+
"resolve_max_bytes_from_env",
|
|
54
|
+
"set_current_span_attribute",
|
|
55
|
+
"set_current_span_error",
|
|
56
|
+
"shutdown_otel",
|
|
57
|
+
"with_span",
|
|
58
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Baggage -> span attribute processor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from opentelemetry import baggage
|
|
8
|
+
from opentelemetry.context import Context
|
|
9
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
|
|
10
|
+
|
|
11
|
+
#: DEFAULT_ALLOWLIST drift across languages would break worker chains;
|
|
12
|
+
#: lockstep tests in each SDK pin this constant at CI time.
|
|
13
|
+
DEFAULT_ALLOWLIST: tuple[str, ...] = (
|
|
14
|
+
"iii.session.id",
|
|
15
|
+
"iii.message.id",
|
|
16
|
+
"iii.function.id",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaggageSpanProcessor(SpanProcessor):
|
|
21
|
+
|
|
22
|
+
def __init__(self, allowlist: Sequence[str] = DEFAULT_ALLOWLIST) -> None:
|
|
23
|
+
self._allowlist: tuple[str, ...] = tuple(allowlist)
|
|
24
|
+
|
|
25
|
+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
|
26
|
+
# NoOp guard: skip allocation when sampler drops the span.
|
|
27
|
+
if not span.is_recording():
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
for key in self._allowlist:
|
|
31
|
+
value = baggage.get_baggage(key, parent_context)
|
|
32
|
+
if value is not None:
|
|
33
|
+
span.set_attribute(key, str(value))
|
|
34
|
+
|
|
35
|
+
def on_end(self, span: ReadableSpan) -> None: # noqa: ARG002
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def shutdown(self) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool: # noqa: ARG002
|
|
42
|
+
return True
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""HTTP client auto-instrumentation for the iii Python SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors the Rust execute_traced_request shape: wraps an httpx Request in an
|
|
4
|
+
OTel CLIENT span with HTTP semantic-convention attributes, injects W3C
|
|
5
|
+
traceparent into outgoing headers, and records exceptions on network errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.propagate import inject
|
|
13
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
14
|
+
|
|
15
|
+
_SAFE_REQUEST_HEADERS = ("content-type", "accept")
|
|
16
|
+
_SAFE_RESPONSE_HEADERS = ("content-type",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _span_name(method: str, path: str | None) -> str:
|
|
20
|
+
return f"{method} {path}" if path else method
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def execute_traced_request(
|
|
24
|
+
client: httpx.AsyncClient,
|
|
25
|
+
request: httpx.Request,
|
|
26
|
+
) -> httpx.Response:
|
|
27
|
+
"""Execute an httpx Request inside an OTel CLIENT span.
|
|
28
|
+
|
|
29
|
+
- Injects W3C traceparent into outgoing request headers.
|
|
30
|
+
- Records HTTP semantic-convention attributes on the span.
|
|
31
|
+
- Sets ERROR span status for responses with status >= 400.
|
|
32
|
+
- Records exceptions for network-level errors.
|
|
33
|
+
"""
|
|
34
|
+
url = request.url
|
|
35
|
+
method = request.method.upper()
|
|
36
|
+
path = url.path or None
|
|
37
|
+
query = url.query
|
|
38
|
+
query_str: str | None
|
|
39
|
+
if isinstance(query, bytes):
|
|
40
|
+
query_str = query.decode() if query else None
|
|
41
|
+
else:
|
|
42
|
+
query_str = query or None
|
|
43
|
+
|
|
44
|
+
attributes: dict[str, str | int] = {
|
|
45
|
+
"http.request.method": method,
|
|
46
|
+
"url.full": str(url),
|
|
47
|
+
}
|
|
48
|
+
if url.host:
|
|
49
|
+
attributes["server.address"] = url.host
|
|
50
|
+
if url.scheme:
|
|
51
|
+
attributes["url.scheme"] = url.scheme
|
|
52
|
+
attributes["network.protocol.name"] = "http"
|
|
53
|
+
if path:
|
|
54
|
+
attributes["url.path"] = path
|
|
55
|
+
if url.port:
|
|
56
|
+
attributes["server.port"] = url.port
|
|
57
|
+
if query_str:
|
|
58
|
+
attributes["url.query"] = query_str
|
|
59
|
+
|
|
60
|
+
tracer = trace.get_tracer("iii-python-sdk")
|
|
61
|
+
name = _span_name(method, path)
|
|
62
|
+
|
|
63
|
+
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT, attributes=attributes) as span:
|
|
64
|
+
carrier: dict[str, str] = {}
|
|
65
|
+
inject(carrier)
|
|
66
|
+
for k, v in carrier.items():
|
|
67
|
+
request.headers[k] = v
|
|
68
|
+
|
|
69
|
+
for h in _SAFE_REQUEST_HEADERS:
|
|
70
|
+
v = request.headers.get(h)
|
|
71
|
+
if v:
|
|
72
|
+
span.set_attribute(f"http.request.header.{h}", v)
|
|
73
|
+
if request.content:
|
|
74
|
+
span.set_attribute("http.request.body.size", len(request.content))
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
response = await client.send(request)
|
|
78
|
+
except httpx.HTTPError as err:
|
|
79
|
+
span.record_exception(err)
|
|
80
|
+
span.set_status(Status(StatusCode.ERROR, str(err)))
|
|
81
|
+
span.set_attribute("error.type", type(err).__name__)
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
span.set_attribute("http.response.status_code", response.status_code)
|
|
85
|
+
cl = response.headers.get("content-length")
|
|
86
|
+
if cl:
|
|
87
|
+
try:
|
|
88
|
+
span.set_attribute("http.response.body.size", int(cl))
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
for h in _SAFE_RESPONSE_HEADERS:
|
|
92
|
+
v = response.headers.get(h)
|
|
93
|
+
if v:
|
|
94
|
+
span.set_attribute(f"http.response.header.{h}", v)
|
|
95
|
+
|
|
96
|
+
if response.status_code >= 400:
|
|
97
|
+
span.set_status(Status(StatusCode.ERROR, str(response.status_code)))
|
|
98
|
+
span.set_attribute("error.type", str(response.status_code))
|
|
99
|
+
else:
|
|
100
|
+
span.set_status(Status(StatusCode.OK))
|
|
101
|
+
|
|
102
|
+
return response
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Logger implementation for the III SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("iii.logger")
|
|
10
|
+
|
|
11
|
+
_SEVERITY_MAP = {
|
|
12
|
+
"info": ("INFO", 9), # SeverityNumber.INFO
|
|
13
|
+
"warn": ("WARN", 13), # SeverityNumber.WARN
|
|
14
|
+
"error": ("ERROR", 17), # SeverityNumber.ERROR
|
|
15
|
+
"debug": ("DEBUG", 5), # SeverityNumber.DEBUG
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_initialized() -> bool:
|
|
20
|
+
"""Internal: True if OTel has been initialized. Imported lazily to avoid a circular import."""
|
|
21
|
+
from .telemetry import _is_initialized as _check
|
|
22
|
+
|
|
23
|
+
return _check()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Logger:
|
|
27
|
+
"""Structured logger that emits logs as OpenTelemetry LogRecords.
|
|
28
|
+
|
|
29
|
+
Every log call automatically captures the active trace and span context,
|
|
30
|
+
correlating your logs with distributed traces without any manual wiring.
|
|
31
|
+
When OTel is not initialized, Logger gracefully falls back to Python
|
|
32
|
+
``logging``.
|
|
33
|
+
|
|
34
|
+
Pass structured data as the second argument to any log method. Using a
|
|
35
|
+
dict of key-value pairs (instead of string interpolation) lets you
|
|
36
|
+
filter, aggregate, and build dashboards in your observability backend.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
>>> from iii import Logger
|
|
40
|
+
>>> logger = Logger()
|
|
41
|
+
>>>
|
|
42
|
+
>>> # Basic logging — trace context is injected automatically
|
|
43
|
+
>>> logger.info('Worker connected')
|
|
44
|
+
>>>
|
|
45
|
+
>>> # Structured context for dashboards and alerting
|
|
46
|
+
>>> logger.info('Order processed', {'order_id': 'ord_123', 'amount': 49.99, 'currency': 'USD'})
|
|
47
|
+
>>> logger.warn('Retry attempt', {'attempt': 3, 'max_retries': 5, 'endpoint': '/api/charge'})
|
|
48
|
+
>>> logger.error('Payment failed', {'order_id': 'ord_123', 'gateway': 'stripe', 'error_code': 'card_declined'})
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
trace_id: str | None = None,
|
|
54
|
+
service_name: str | None = None,
|
|
55
|
+
span_id: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._trace_id = trace_id
|
|
58
|
+
self._service_name = service_name or ""
|
|
59
|
+
self._span_id = span_id
|
|
60
|
+
|
|
61
|
+
def _emit_otel(self, level: str, message: str, data: Any = None) -> bool:
|
|
62
|
+
"""Emit an OTel LogRecord. Returns True if emitted, False if OTel not active."""
|
|
63
|
+
if not _is_initialized():
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
from opentelemetry import _logs, trace
|
|
67
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
68
|
+
|
|
69
|
+
severity_text, severity_num = _SEVERITY_MAP[level]
|
|
70
|
+
otel_logger = _logs.get_logger("iii.logger")
|
|
71
|
+
attrs: dict[str, Any] = {"service.name": self._service_name}
|
|
72
|
+
if data is not None:
|
|
73
|
+
attrs["log.data"] = data
|
|
74
|
+
|
|
75
|
+
span_ctx = trace.get_current_span().get_span_context()
|
|
76
|
+
|
|
77
|
+
if self._trace_id is not None:
|
|
78
|
+
trace_id = int(self._trace_id, 16)
|
|
79
|
+
elif span_ctx.is_valid:
|
|
80
|
+
trace_id = span_ctx.trace_id
|
|
81
|
+
else:
|
|
82
|
+
trace_id = 0
|
|
83
|
+
|
|
84
|
+
if self._span_id is not None:
|
|
85
|
+
span_id = int(self._span_id, 16)
|
|
86
|
+
elif span_ctx.is_valid:
|
|
87
|
+
span_id = span_ctx.span_id
|
|
88
|
+
else:
|
|
89
|
+
span_id = 0
|
|
90
|
+
|
|
91
|
+
trace_flags = span_ctx.trace_flags if span_ctx.is_valid else trace.TraceFlags(0)
|
|
92
|
+
|
|
93
|
+
record = LogRecord(
|
|
94
|
+
timestamp=time.time_ns(),
|
|
95
|
+
observed_timestamp=time.time_ns(),
|
|
96
|
+
severity_text=severity_text,
|
|
97
|
+
severity_number=SeverityNumber(severity_num),
|
|
98
|
+
body=message,
|
|
99
|
+
attributes=attrs,
|
|
100
|
+
trace_id=trace_id,
|
|
101
|
+
span_id=span_id,
|
|
102
|
+
trace_flags=trace_flags,
|
|
103
|
+
)
|
|
104
|
+
otel_logger.emit(record)
|
|
105
|
+
return True
|
|
106
|
+
except Exception:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def _emit(self, level: str, message: str, data: Any = None) -> None:
|
|
110
|
+
"""Emit a log message via OTel, or Python logging as fallback."""
|
|
111
|
+
if self._emit_otel(level, message, data):
|
|
112
|
+
return
|
|
113
|
+
_LOG_METHODS = {
|
|
114
|
+
"info": log.info,
|
|
115
|
+
"warn": log.warning,
|
|
116
|
+
"error": log.error,
|
|
117
|
+
"debug": log.debug,
|
|
118
|
+
}
|
|
119
|
+
log_fn = _LOG_METHODS.get(level, log.info)
|
|
120
|
+
log_fn("[%s] %s", self._service_name, message, extra={"data": data})
|
|
121
|
+
|
|
122
|
+
def info(self, message: str, data: Any = None) -> None:
|
|
123
|
+
"""Log an info-level message.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
message: Human-readable log message.
|
|
127
|
+
data: Structured context attached as OTel log attributes.
|
|
128
|
+
Use dicts of key-value pairs to enable filtering and
|
|
129
|
+
aggregation in your observability backend (e.g. Grafana,
|
|
130
|
+
Datadog, New Relic).
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
>>> logger.info('Order processed', {'order_id': 'ord_123', 'status': 'completed'})
|
|
134
|
+
"""
|
|
135
|
+
self._emit("info", message, data)
|
|
136
|
+
|
|
137
|
+
def warn(self, message: str, data: Any = None) -> None:
|
|
138
|
+
"""Log a warning-level message.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
message: Human-readable log message.
|
|
142
|
+
data: Structured context attached as OTel log attributes.
|
|
143
|
+
Use dicts of key-value pairs to enable filtering and
|
|
144
|
+
aggregation in your observability backend (e.g. Grafana,
|
|
145
|
+
Datadog, New Relic).
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
>>> logger.warn('Retry attempt', {'attempt': 3, 'max_retries': 5, 'endpoint': '/api/charge'})
|
|
149
|
+
"""
|
|
150
|
+
self._emit("warn", message, data)
|
|
151
|
+
|
|
152
|
+
def error(self, message: str, data: Any = None) -> None:
|
|
153
|
+
"""Log an error-level message.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
message: Human-readable log message.
|
|
157
|
+
data: Structured context attached as OTel log attributes.
|
|
158
|
+
Use dicts of key-value pairs to enable filtering and
|
|
159
|
+
aggregation in your observability backend (e.g. Grafana,
|
|
160
|
+
Datadog, New Relic).
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
>>> logger.error('Payment failed', {
|
|
164
|
+
... 'order_id': 'ord_123',
|
|
165
|
+
... 'gateway': 'stripe',
|
|
166
|
+
... 'error_code': 'card_declined',
|
|
167
|
+
... })
|
|
168
|
+
"""
|
|
169
|
+
self._emit("error", message, data)
|
|
170
|
+
|
|
171
|
+
def debug(self, message: str, data: Any = None) -> None:
|
|
172
|
+
"""Log a debug-level message.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
message: Human-readable log message.
|
|
176
|
+
data: Structured context attached as OTel log attributes.
|
|
177
|
+
Use dicts of key-value pairs to enable filtering and
|
|
178
|
+
aggregation in your observability backend (e.g. Grafana,
|
|
179
|
+
Datadog, New Relic).
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
>>> logger.debug('Cache lookup', {'key': 'user:42', 'hit': False})
|
|
183
|
+
"""
|
|
184
|
+
self._emit("debug", message, data)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Payload redaction + truncation for invocation event capture."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
REDACTED_PLACEHOLDER = "[REDACTED]"
|
|
10
|
+
_TRUNCATION_MARKER = '..."[TRUNCATED]"'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_max_bytes_from_env() -> Optional[int]:
|
|
14
|
+
raw = os.environ.get("III_TRACE_PAYLOAD_MAX_BYTES")
|
|
15
|
+
if raw is None:
|
|
16
|
+
return None
|
|
17
|
+
trimmed = raw.strip()
|
|
18
|
+
if not trimmed or trimmed.lower() == "unlimited":
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
parsed = int(trimmed)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return None
|
|
24
|
+
if parsed <= 0:
|
|
25
|
+
return None
|
|
26
|
+
return parsed
|
|
27
|
+
|
|
28
|
+
_SENSITIVE_FRAGMENTS = (
|
|
29
|
+
"api_key",
|
|
30
|
+
"apikey",
|
|
31
|
+
"api-key",
|
|
32
|
+
"password",
|
|
33
|
+
"secret",
|
|
34
|
+
"credential",
|
|
35
|
+
"authorization",
|
|
36
|
+
"auth_token",
|
|
37
|
+
"access_token",
|
|
38
|
+
"refresh_token",
|
|
39
|
+
"bearer",
|
|
40
|
+
"private_key",
|
|
41
|
+
"client_secret",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
46
|
+
lower = key.lower()
|
|
47
|
+
if any(fragment in lower for fragment in _SENSITIVE_FRAGMENTS):
|
|
48
|
+
return True
|
|
49
|
+
# ``token`` alone is too common a substring; require whole-key or suffix match.
|
|
50
|
+
return lower == "token" or lower.endswith("_token") or lower.endswith("-token")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def redact(value: Any) -> Any:
|
|
54
|
+
if isinstance(value, dict):
|
|
55
|
+
return {
|
|
56
|
+
k: REDACTED_PLACEHOLDER if _is_sensitive_key(k) else redact(v)
|
|
57
|
+
for k, v in value.items()
|
|
58
|
+
}
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
return [redact(item) for item in value]
|
|
61
|
+
if isinstance(value, tuple):
|
|
62
|
+
return tuple(redact(item) for item in value)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def redact_and_truncate(
|
|
67
|
+
value: Any, max_bytes: Optional[int] = None
|
|
68
|
+
) -> tuple[str, bool]:
|
|
69
|
+
redacted = redact(value)
|
|
70
|
+
try:
|
|
71
|
+
serialized = json.dumps(redacted, default=str, ensure_ascii=False)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
serialized = "null"
|
|
74
|
+
|
|
75
|
+
if max_bytes is None or max_bytes <= 0:
|
|
76
|
+
return serialized, False
|
|
77
|
+
|
|
78
|
+
encoded = serialized.encode("utf-8")
|
|
79
|
+
if len(encoded) <= max_bytes:
|
|
80
|
+
return serialized, False
|
|
81
|
+
|
|
82
|
+
marker_len = len(_TRUNCATION_MARKER.encode("utf-8"))
|
|
83
|
+
if max_bytes <= marker_len:
|
|
84
|
+
return _TRUNCATION_MARKER[:max_bytes], True
|
|
85
|
+
|
|
86
|
+
cap = max_bytes - marker_len
|
|
87
|
+
# Walk back to a UTF-8 boundary so we don't emit half-codepoints.
|
|
88
|
+
cut = cap
|
|
89
|
+
while cut > 0 and (encoded[cut] & 0xC0) == 0x80:
|
|
90
|
+
cut -= 1
|
|
91
|
+
truncated = encoded[:cut].decode("utf-8", errors="ignore") + _TRUNCATION_MARKER
|
|
92
|
+
return truncated, True
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""WebSocket reconnection configuration for iii observability connections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ReconnectionConfig:
|
|
10
|
+
"""Configuration for WebSocket reconnection behavior.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
initial_delay_ms: Starting delay in milliseconds. Default ``1000``.
|
|
14
|
+
max_delay_ms: Maximum delay cap in milliseconds. Default ``30000``.
|
|
15
|
+
backoff_multiplier: Exponential backoff multiplier. Default ``2.0``.
|
|
16
|
+
jitter_factor: Random jitter factor (0--1). Default ``0.3``.
|
|
17
|
+
max_retries: Maximum retry attempts. ``-1`` for infinite. Default ``-1``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
initial_delay_ms: int = 1000
|
|
21
|
+
max_delay_ms: int = 30000
|
|
22
|
+
backoff_multiplier: float = 2.0
|
|
23
|
+
jitter_factor: float = 0.3
|
|
24
|
+
max_retries: int = -1
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""High-level span operations so consumers don't need ``opentelemetry``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from opentelemetry import trace
|
|
8
|
+
from opentelemetry.trace import Status, StatusCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def current_span_is_recording() -> bool:
|
|
12
|
+
"""Returns ``False`` when there is no active span or the sampler dropped it."""
|
|
13
|
+
span = trace.get_current_span()
|
|
14
|
+
return bool(span and span.is_recording())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_current_span_attribute(key: str, value: Any) -> None:
|
|
18
|
+
"""No-op when the current span is not recording."""
|
|
19
|
+
span = trace.get_current_span()
|
|
20
|
+
if not span or not span.is_recording():
|
|
21
|
+
return
|
|
22
|
+
span.set_attribute(key, value)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_current_span_error(message: str) -> None:
|
|
26
|
+
"""No-op when there is no active span."""
|
|
27
|
+
span = trace.get_current_span()
|
|
28
|
+
if not span:
|
|
29
|
+
return
|
|
30
|
+
span.set_status(Status(StatusCode.ERROR, message))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def record_span_event(name: str, attrs: dict[str, Any] | None = None) -> None:
|
|
34
|
+
"""No-op when the current span is not recording."""
|
|
35
|
+
span = trace.get_current_span()
|
|
36
|
+
if not span or not span.is_recording():
|
|
37
|
+
return
|
|
38
|
+
span.add_event(name, attributes=attrs or {})
|