iii-sdk 0.11.7.dev1__tar.gz → 0.11.7.dev3__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.
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/PKG-INFO +1 -1
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/pyproject.toml +1 -1
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/__init__.py +24 -0
- iii_sdk-0.11.7.dev3/src/iii/baggage_span_processor.py +42 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/iii.py +41 -0
- iii_sdk-0.11.7.dev3/src/iii/payload.py +92 -0
- iii_sdk-0.11.7.dev3/src/iii/span_ops.py +38 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/stream.py +65 -8
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/telemetry.py +6 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_api_triggers.py +49 -0
- iii_sdk-0.11.7.dev3/tests/test_baggage_span_processor.py +162 -0
- iii_sdk-0.11.7.dev3/tests/test_payload.py +173 -0
- iii_sdk-0.11.7.dev3/tests/test_span_ops.py +93 -0
- iii_sdk-0.11.7.dev3/tests/test_stream_models.py +134 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/uv.lock +1078 -1078
- iii_sdk-0.11.7.dev1/tests/test_stream_models.py +0 -73
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/.gitignore +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/README.md +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/channels.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/errors.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/format_utils.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/iii_constants.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/iii_types.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/logger.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/otel_worker_gauges.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/state.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/telemetry_exporters.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/telemetry_types.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/triggers.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/types.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/utils.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/worker_metrics.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/conftest.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_async_api.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_bridge.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_channel_close_delay.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_context_propagation.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_data_channels.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_errors.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_format_utils.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_healthcheck.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_hold_process.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_http_external_functions_integration.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_iii_registration_dedup.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_init_api.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_invocation_exception.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_logger_function_ids.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_logger_otel.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_middleware.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_pubsub.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_queue_integration.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_rbac_workers.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_register_function_args.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_state.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_streams.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_streams_runtime_annotations.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_sync_api.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_telemetry.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_telemetry_exporters.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_telemetry_types.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_trace_helpers.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_trigger_metadata.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_utils.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_worker_metadata.py +0 -0
- {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_worker_metrics.py +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""III SDK for Python."""
|
|
2
2
|
|
|
3
|
+
from .baggage_span_processor import DEFAULT_ALLOWLIST, BaggageSpanProcessor
|
|
3
4
|
from .channels import ChannelReader, ChannelWriter
|
|
4
5
|
from .errors import IIIForbiddenError, IIIInvocationError, IIITimeoutError
|
|
5
6
|
from .format_utils import extract_request_format, extract_response_format, python_type_to_format
|
|
@@ -35,6 +36,18 @@ from .iii_types import (
|
|
|
35
36
|
TriggerTypeInfo,
|
|
36
37
|
)
|
|
37
38
|
from .logger import Logger
|
|
39
|
+
from .payload import (
|
|
40
|
+
REDACTED_PLACEHOLDER,
|
|
41
|
+
redact,
|
|
42
|
+
redact_and_truncate,
|
|
43
|
+
resolve_max_bytes_from_env,
|
|
44
|
+
)
|
|
45
|
+
from .span_ops import (
|
|
46
|
+
current_span_is_recording,
|
|
47
|
+
record_span_event,
|
|
48
|
+
set_current_span_attribute,
|
|
49
|
+
set_current_span_error,
|
|
50
|
+
)
|
|
38
51
|
from .stream import (
|
|
39
52
|
IStream,
|
|
40
53
|
StreamChangeEvent,
|
|
@@ -59,6 +72,17 @@ from .types import (
|
|
|
59
72
|
from .utils import http
|
|
60
73
|
|
|
61
74
|
__all__ = [
|
|
75
|
+
# Telemetry helpers
|
|
76
|
+
"BaggageSpanProcessor",
|
|
77
|
+
"DEFAULT_ALLOWLIST",
|
|
78
|
+
"REDACTED_PLACEHOLDER",
|
|
79
|
+
"current_span_is_recording",
|
|
80
|
+
"record_span_event",
|
|
81
|
+
"set_current_span_attribute",
|
|
82
|
+
"set_current_span_error",
|
|
83
|
+
"redact",
|
|
84
|
+
"redact_and_truncate",
|
|
85
|
+
"resolve_max_bytes_from_env",
|
|
62
86
|
# Channels
|
|
63
87
|
"ChannelReader",
|
|
64
88
|
"ChannelWriter",
|
|
@@ -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
|
|
@@ -500,17 +500,58 @@ class III:
|
|
|
500
500
|
propagate.extract(carrier) if carrier else otel_context.get_current()
|
|
501
501
|
)
|
|
502
502
|
tracer = trace.get_tracer("iii-python-sdk")
|
|
503
|
+
import os
|
|
504
|
+
|
|
505
|
+
from .payload import redact_and_truncate, resolve_max_bytes_from_env
|
|
506
|
+
|
|
507
|
+
trace_payloads = os.environ.get("III_DISABLE_TRACE_PAYLOADS", "").lower() not in (
|
|
508
|
+
"1",
|
|
509
|
+
"true",
|
|
510
|
+
)
|
|
511
|
+
payload_max_bytes = resolve_max_bytes_from_env()
|
|
512
|
+
|
|
503
513
|
with tracer.start_as_current_span(
|
|
504
514
|
f"call {handler.__name__}",
|
|
505
515
|
context=parent_ctx,
|
|
506
516
|
kind=trace.SpanKind.SERVER,
|
|
507
517
|
) as span:
|
|
518
|
+
if trace_payloads and span.is_recording():
|
|
519
|
+
input_json, input_truncated = redact_and_truncate(data, payload_max_bytes)
|
|
520
|
+
span.add_event(
|
|
521
|
+
"iii.invocation.input",
|
|
522
|
+
attributes={
|
|
523
|
+
"iii.payload.json": input_json,
|
|
524
|
+
"iii.payload.truncated": input_truncated,
|
|
525
|
+
},
|
|
526
|
+
)
|
|
508
527
|
try:
|
|
509
528
|
result = await handler(data)
|
|
529
|
+
if trace_payloads and span.is_recording():
|
|
530
|
+
out_json, out_truncated = redact_and_truncate(result, payload_max_bytes)
|
|
531
|
+
span.add_event(
|
|
532
|
+
"iii.invocation.output",
|
|
533
|
+
attributes={
|
|
534
|
+
"iii.payload.json": out_json,
|
|
535
|
+
"iii.payload.truncated": out_truncated,
|
|
536
|
+
"iii.payload.ok": True,
|
|
537
|
+
},
|
|
538
|
+
)
|
|
510
539
|
span.set_status(trace.StatusCode.OK)
|
|
511
540
|
response_traceparent = self._inject_traceparent()
|
|
512
541
|
return result, response_traceparent
|
|
513
542
|
except Exception as e:
|
|
543
|
+
if trace_payloads and span.is_recording():
|
|
544
|
+
err_json, err_truncated = redact_and_truncate(
|
|
545
|
+
{"error": str(e)}, payload_max_bytes
|
|
546
|
+
)
|
|
547
|
+
span.add_event(
|
|
548
|
+
"iii.invocation.output",
|
|
549
|
+
attributes={
|
|
550
|
+
"iii.payload.json": err_json,
|
|
551
|
+
"iii.payload.truncated": err_truncated,
|
|
552
|
+
"iii.payload.ok": False,
|
|
553
|
+
},
|
|
554
|
+
)
|
|
514
555
|
span.record_exception(e)
|
|
515
556
|
span.set_status(trace.StatusCode.ERROR, str(e))
|
|
516
557
|
response_traceparent = self._inject_traceparent()
|
|
@@ -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
|
|
@@ -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 {})
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
6
|
from typing import Any, Generic, List, Literal, TypeVar
|
|
7
7
|
|
|
8
|
-
from pydantic import BaseModel, Field
|
|
8
|
+
from pydantic import BaseModel, Field, model_serializer
|
|
9
9
|
|
|
10
10
|
TData = TypeVar("TData")
|
|
11
11
|
|
|
@@ -117,11 +117,13 @@ class StreamUpdateResult(BaseModel, Generic[TData]):
|
|
|
117
117
|
|
|
118
118
|
old_value: TData | None = None
|
|
119
119
|
new_value: TData
|
|
120
|
-
# Per-op errors.
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
120
|
+
# Per-op errors. Emitted by ``merge`` and ``append`` for validation
|
|
121
|
+
# rejections (path/value bounds, proto-pollution segments) and by
|
|
122
|
+
# ``append`` for the ``append.type_mismatch`` and
|
|
123
|
+
# ``append.target_not_object`` surfaces. Field is omitted from the
|
|
124
|
+
# JSON wire when empty. ``default_factory`` is used (not ``= []``)
|
|
125
|
+
# to keep Pydantic's parameterized-Generic + default handling
|
|
126
|
+
# well-behaved across Python versions.
|
|
125
127
|
errors: list[UpdateOpError] = Field(default_factory=list)
|
|
126
128
|
|
|
127
129
|
|
|
@@ -156,12 +158,57 @@ class UpdateDecrement(BaseModel):
|
|
|
156
158
|
|
|
157
159
|
|
|
158
160
|
class UpdateAppend(BaseModel):
|
|
159
|
-
"""Append
|
|
161
|
+
"""Append an element to an array, concatenate a string, or push at a nested path.
|
|
162
|
+
|
|
163
|
+
The target is the root (when ``path`` is omitted, an empty string,
|
|
164
|
+
or an empty list), a single first-level key (when ``path`` is a
|
|
165
|
+
non-empty string), or an arbitrary nested location (when ``path``
|
|
166
|
+
is a list of literal segments).
|
|
167
|
+
|
|
168
|
+
Path forms accepted (mirrors :class:`UpdateMerge` after #1547):
|
|
169
|
+
- ``None`` / ``""`` / ``[]``: append at the root.
|
|
170
|
+
- ``"foo"``: append at the first-level key ``foo``. A dotted
|
|
171
|
+
string like ``"a.b"`` is the literal key ``"a.b"``, *not*
|
|
172
|
+
traversed as ``a -> b``.
|
|
173
|
+
- ``["a", "b", "c"]``: nested path; each element is a literal
|
|
174
|
+
segment.
|
|
175
|
+
|
|
176
|
+
Engine semantics:
|
|
177
|
+
- Missing/non-object intermediates along a nested path are
|
|
178
|
+
auto-created/replaced with ``{}``.
|
|
179
|
+
- At the leaf:
|
|
180
|
+
- missing/null + nested path -> ``[value]`` (always an array)
|
|
181
|
+
- missing/null + single-string path -> string-as-string for
|
|
182
|
+
the string-concat tier, otherwise ``[value]``
|
|
183
|
+
- existing array -> push
|
|
184
|
+
- existing string + string value -> concatenate
|
|
185
|
+
- existing object/scalar at the leaf -> ``append.type_mismatch``
|
|
186
|
+
|
|
187
|
+
Validation: invalid paths (depth > 32 segments, segment > 256
|
|
188
|
+
bytes, or any ``__proto__`` / ``constructor`` / ``prototype``
|
|
189
|
+
segment) are rejected with a structured error in the ``errors``
|
|
190
|
+
field of the ``state::update`` / ``stream::update`` response. The
|
|
191
|
+
append does not apply when an error is returned for that op.
|
|
192
|
+
"""
|
|
160
193
|
|
|
161
194
|
type: str = "append"
|
|
162
|
-
|
|
195
|
+
# Optional. Accepts a single string (legacy / first-level key) or
|
|
196
|
+
# a list of literal segments (nested append). ``None`` / ``""`` /
|
|
197
|
+
# ``[]`` all route to root append.
|
|
198
|
+
path: str | list[str] | None = None
|
|
163
199
|
value: Any
|
|
164
200
|
|
|
201
|
+
@model_serializer(mode="wrap")
|
|
202
|
+
def _omit_none_path(self, handler): # type: ignore[no-untyped-def]
|
|
203
|
+
# Drop ``path: None`` from the wire so cross-SDK consumers see
|
|
204
|
+
# the field absent rather than ``null``. Mirrors the Rust
|
|
205
|
+
# ``#[serde(skip_serializing_if = "Option::is_none")]`` on
|
|
206
|
+
# ``UpdateOp::Append.path``.
|
|
207
|
+
data = handler(self)
|
|
208
|
+
if data.get("path") is None:
|
|
209
|
+
data.pop("path", None)
|
|
210
|
+
return data
|
|
211
|
+
|
|
165
212
|
|
|
166
213
|
class UpdateRemove(BaseModel):
|
|
167
214
|
"""Remove operation for stream update."""
|
|
@@ -205,6 +252,16 @@ class UpdateMerge(BaseModel):
|
|
|
205
252
|
path: str | list[str] | None = None
|
|
206
253
|
value: Any
|
|
207
254
|
|
|
255
|
+
@model_serializer(mode="wrap")
|
|
256
|
+
def _omit_none_path(self, handler): # type: ignore[no-untyped-def]
|
|
257
|
+
# Mirrors the same skip-when-none rule applied to
|
|
258
|
+
# ``UpdateOp::Merge.path`` in the Rust SDK so cross-SDK wire
|
|
259
|
+
# payloads are byte-identical for root merges.
|
|
260
|
+
data = handler(self)
|
|
261
|
+
if data.get("path") is None:
|
|
262
|
+
data.pop("path", None)
|
|
263
|
+
return data
|
|
264
|
+
|
|
208
265
|
|
|
209
266
|
UpdateOp = UpdateSet | UpdateIncrement | UpdateDecrement | UpdateAppend | UpdateRemove | UpdateMerge
|
|
210
267
|
|
|
@@ -107,6 +107,12 @@ def init_otel(
|
|
|
107
107
|
|
|
108
108
|
span_exporter = EngineSpanExporter(_connection)
|
|
109
109
|
provider = TracerProvider(resource=resource)
|
|
110
|
+
# BaggageSpanProcessor must register first: on_start fires in
|
|
111
|
+
# registration order, so baggage entries are materialized as span
|
|
112
|
+
# attributes before the batch exporter reads them.
|
|
113
|
+
from .baggage_span_processor import BaggageSpanProcessor
|
|
114
|
+
|
|
115
|
+
provider.add_span_processor(BaggageSpanProcessor())
|
|
110
116
|
provider.add_span_processor(BatchSpanProcessor(span_exporter)) # type: ignore[arg-type]
|
|
111
117
|
trace.set_tracer_provider(provider)
|
|
112
118
|
_tracer = trace.get_tracer("iii-python-sdk")
|
|
@@ -87,6 +87,55 @@ async def test_post_endpoint_with_body(engine_http_url, iii_client: III):
|
|
|
87
87
|
trigger.unregister()
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_raw_json_request_body(engine_http_url, iii_client: III):
|
|
92
|
+
raw_json = '{"z":2, "a":1}'
|
|
93
|
+
function_id = "test::api::json::raw::py"
|
|
94
|
+
|
|
95
|
+
@http
|
|
96
|
+
async def handler(req: HttpRequest, response: HttpResponse):
|
|
97
|
+
raw = await req.request_body.read_all()
|
|
98
|
+
|
|
99
|
+
await response.status(200)
|
|
100
|
+
await response.headers({"content-type": "application/json"})
|
|
101
|
+
result = json.dumps(
|
|
102
|
+
{
|
|
103
|
+
"parsed_body": req.body,
|
|
104
|
+
"raw_body": raw.decode("utf-8"),
|
|
105
|
+
}
|
|
106
|
+
).encode("utf-8")
|
|
107
|
+
await response.writer.write(result)
|
|
108
|
+
await response.writer.close_async()
|
|
109
|
+
|
|
110
|
+
fn_ref = iii_client.register_function(function_id, handler)
|
|
111
|
+
trigger = iii_client.register_trigger(
|
|
112
|
+
{
|
|
113
|
+
"type": "http",
|
|
114
|
+
"function_id": function_id,
|
|
115
|
+
"config": {
|
|
116
|
+
"api_path": "/test/py/json/raw",
|
|
117
|
+
"http_method": "POST",
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
time.sleep(0.3)
|
|
123
|
+
|
|
124
|
+
async with aiohttp.ClientSession() as session:
|
|
125
|
+
async with session.post(
|
|
126
|
+
f"{engine_http_url}/test/py/json/raw",
|
|
127
|
+
headers={"content-type": "application/json"},
|
|
128
|
+
data=raw_json,
|
|
129
|
+
) as resp:
|
|
130
|
+
assert resp.status == 200
|
|
131
|
+
data = await resp.json()
|
|
132
|
+
assert data["parsed_body"] == {"z": 2, "a": 1}
|
|
133
|
+
assert data["raw_body"] == raw_json
|
|
134
|
+
|
|
135
|
+
fn_ref.unregister()
|
|
136
|
+
trigger.unregister()
|
|
137
|
+
|
|
138
|
+
|
|
90
139
|
@pytest.mark.asyncio
|
|
91
140
|
async def test_path_parameters(engine_http_url, iii_client: III):
|
|
92
141
|
"""Verify path parameters are extracted correctly."""
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Unit tests for BaggageSpanProcessor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from opentelemetry import baggage, context
|
|
6
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
7
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
8
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
|
9
|
+
InMemorySpanExporter,
|
|
10
|
+
)
|
|
11
|
+
from opentelemetry.sdk.trace.sampling import ALWAYS_OFF
|
|
12
|
+
|
|
13
|
+
from iii.baggage_span_processor import (
|
|
14
|
+
DEFAULT_ALLOWLIST,
|
|
15
|
+
BaggageSpanProcessor,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_test_provider(
|
|
20
|
+
processor: BaggageSpanProcessor,
|
|
21
|
+
) -> tuple[TracerProvider, InMemorySpanExporter]:
|
|
22
|
+
exporter = InMemorySpanExporter()
|
|
23
|
+
provider = TracerProvider()
|
|
24
|
+
provider.add_span_processor(processor)
|
|
25
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
26
|
+
return provider, exporter
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _attach_baggage(entries: dict[str, str]):
|
|
30
|
+
ctx = context.get_current()
|
|
31
|
+
for key, value in entries.items():
|
|
32
|
+
ctx = baggage.set_baggage(key, value, ctx)
|
|
33
|
+
return context.attach(ctx)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _first_span_attr(exporter: InMemorySpanExporter, key: str) -> object | None:
|
|
37
|
+
spans = exporter.get_finished_spans()
|
|
38
|
+
if not spans:
|
|
39
|
+
return None
|
|
40
|
+
return spans[0].attributes.get(key) if spans[0].attributes else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_copies_default_allowlist_from_baggage_to_attributes() -> None:
|
|
44
|
+
provider, exporter = _build_test_provider(BaggageSpanProcessor())
|
|
45
|
+
tracer = provider.get_tracer("test")
|
|
46
|
+
|
|
47
|
+
token = _attach_baggage(
|
|
48
|
+
{
|
|
49
|
+
"iii.session.id": "S-1",
|
|
50
|
+
"iii.message.id": "M-1",
|
|
51
|
+
"iii.function.id": "auth::set_token",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
with tracer.start_as_current_span("inner"):
|
|
56
|
+
pass
|
|
57
|
+
finally:
|
|
58
|
+
context.detach(token)
|
|
59
|
+
|
|
60
|
+
assert _first_span_attr(exporter, "iii.session.id") == "S-1"
|
|
61
|
+
assert _first_span_attr(exporter, "iii.message.id") == "M-1"
|
|
62
|
+
assert _first_span_attr(exporter, "iii.function.id") == "auth::set_token"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_missing_baggage_entry_means_attribute_not_set() -> None:
|
|
66
|
+
provider, exporter = _build_test_provider(BaggageSpanProcessor())
|
|
67
|
+
tracer = provider.get_tracer("test")
|
|
68
|
+
|
|
69
|
+
token = _attach_baggage({"iii.message.id": "M-only"})
|
|
70
|
+
try:
|
|
71
|
+
with tracer.start_as_current_span("inner"):
|
|
72
|
+
pass
|
|
73
|
+
finally:
|
|
74
|
+
context.detach(token)
|
|
75
|
+
|
|
76
|
+
assert _first_span_attr(exporter, "iii.message.id") == "M-only"
|
|
77
|
+
assert _first_span_attr(exporter, "iii.session.id") is None
|
|
78
|
+
assert _first_span_attr(exporter, "iii.function.id") is None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_baggage_entries_not_in_allowlist_are_dropped() -> None:
|
|
82
|
+
provider, exporter = _build_test_provider(BaggageSpanProcessor())
|
|
83
|
+
tracer = provider.get_tracer("test")
|
|
84
|
+
|
|
85
|
+
token = _attach_baggage(
|
|
86
|
+
{
|
|
87
|
+
"iii.message.id": "M",
|
|
88
|
+
"tenant.id": "t-42",
|
|
89
|
+
"debug.feature_flag": "on",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
with tracer.start_as_current_span("inner"):
|
|
94
|
+
pass
|
|
95
|
+
finally:
|
|
96
|
+
context.detach(token)
|
|
97
|
+
|
|
98
|
+
assert _first_span_attr(exporter, "iii.message.id") == "M"
|
|
99
|
+
assert _first_span_attr(exporter, "tenant.id") is None
|
|
100
|
+
assert _first_span_attr(exporter, "debug.feature_flag") is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_custom_allowlist_is_honored() -> None:
|
|
104
|
+
processor = BaggageSpanProcessor(allowlist=["tenant.id", "iii.message.id"])
|
|
105
|
+
provider, exporter = _build_test_provider(processor)
|
|
106
|
+
tracer = provider.get_tracer("test")
|
|
107
|
+
|
|
108
|
+
token = _attach_baggage(
|
|
109
|
+
{
|
|
110
|
+
"tenant.id": "t-1",
|
|
111
|
+
"iii.message.id": "M",
|
|
112
|
+
"iii.session.id": "S-not-copied",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
with tracer.start_as_current_span("inner"):
|
|
117
|
+
pass
|
|
118
|
+
finally:
|
|
119
|
+
context.detach(token)
|
|
120
|
+
|
|
121
|
+
assert _first_span_attr(exporter, "tenant.id") == "t-1"
|
|
122
|
+
assert _first_span_attr(exporter, "iii.message.id") == "M"
|
|
123
|
+
assert _first_span_attr(exporter, "iii.session.id") is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_empty_parent_context_produces_no_attributes() -> None:
|
|
127
|
+
provider, exporter = _build_test_provider(BaggageSpanProcessor())
|
|
128
|
+
tracer = provider.get_tracer("test")
|
|
129
|
+
|
|
130
|
+
with tracer.start_as_current_span("inner"):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
assert _first_span_attr(exporter, "iii.session.id") is None
|
|
134
|
+
assert _first_span_attr(exporter, "iii.message.id") is None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_noop_guard_skips_processing_when_sampled_out() -> None:
|
|
138
|
+
exporter = InMemorySpanExporter()
|
|
139
|
+
provider = TracerProvider(sampler=ALWAYS_OFF)
|
|
140
|
+
provider.add_span_processor(BaggageSpanProcessor())
|
|
141
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
142
|
+
tracer = provider.get_tracer("test")
|
|
143
|
+
|
|
144
|
+
token = _attach_baggage(
|
|
145
|
+
{"iii.session.id": "S-1", "iii.message.id": "M-1"}
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
with tracer.start_as_current_span("inner"):
|
|
149
|
+
pass
|
|
150
|
+
finally:
|
|
151
|
+
context.detach(token)
|
|
152
|
+
|
|
153
|
+
assert exporter.get_finished_spans() == ()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_default_allowlist_matches_other_sdks() -> None:
|
|
157
|
+
"""DEFAULT_ALLOWLIST drift across languages would break worker chains."""
|
|
158
|
+
assert tuple(DEFAULT_ALLOWLIST) == (
|
|
159
|
+
"iii.session.id",
|
|
160
|
+
"iii.message.id",
|
|
161
|
+
"iii.function.id",
|
|
162
|
+
)
|