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.
@@ -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 {})