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.
Files changed (65) hide show
  1. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/PKG-INFO +1 -1
  2. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/pyproject.toml +1 -1
  3. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/__init__.py +24 -0
  4. iii_sdk-0.11.7.dev3/src/iii/baggage_span_processor.py +42 -0
  5. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/iii.py +41 -0
  6. iii_sdk-0.11.7.dev3/src/iii/payload.py +92 -0
  7. iii_sdk-0.11.7.dev3/src/iii/span_ops.py +38 -0
  8. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/stream.py +65 -8
  9. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/telemetry.py +6 -0
  10. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_api_triggers.py +49 -0
  11. iii_sdk-0.11.7.dev3/tests/test_baggage_span_processor.py +162 -0
  12. iii_sdk-0.11.7.dev3/tests/test_payload.py +173 -0
  13. iii_sdk-0.11.7.dev3/tests/test_span_ops.py +93 -0
  14. iii_sdk-0.11.7.dev3/tests/test_stream_models.py +134 -0
  15. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/uv.lock +1078 -1078
  16. iii_sdk-0.11.7.dev1/tests/test_stream_models.py +0 -73
  17. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/.gitignore +0 -0
  18. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/README.md +0 -0
  19. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/channels.py +0 -0
  20. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/errors.py +0 -0
  21. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/format_utils.py +0 -0
  22. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/iii_constants.py +0 -0
  23. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/iii_types.py +0 -0
  24. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/logger.py +0 -0
  25. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/otel_worker_gauges.py +0 -0
  26. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/state.py +0 -0
  27. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/telemetry_exporters.py +0 -0
  28. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/telemetry_types.py +0 -0
  29. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/triggers.py +0 -0
  30. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/types.py +0 -0
  31. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/utils.py +0 -0
  32. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/src/iii/worker_metrics.py +0 -0
  33. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/conftest.py +0 -0
  34. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_async_api.py +0 -0
  35. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_bridge.py +0 -0
  36. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_channel_close_delay.py +0 -0
  37. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_context_propagation.py +0 -0
  38. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_data_channels.py +0 -0
  39. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_errors.py +0 -0
  40. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_format_utils.py +0 -0
  41. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_healthcheck.py +0 -0
  42. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_hold_process.py +0 -0
  43. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_http_external_functions_integration.py +0 -0
  44. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_iii_registration_dedup.py +0 -0
  45. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_init_api.py +0 -0
  46. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_invocation_exception.py +0 -0
  47. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_logger_function_ids.py +0 -0
  48. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_logger_otel.py +0 -0
  49. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_middleware.py +0 -0
  50. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_pubsub.py +0 -0
  51. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_queue_integration.py +0 -0
  52. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_rbac_workers.py +0 -0
  53. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_register_function_args.py +0 -0
  54. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_state.py +0 -0
  55. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_streams.py +0 -0
  56. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_streams_runtime_annotations.py +0 -0
  57. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_sync_api.py +0 -0
  58. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_telemetry.py +0 -0
  59. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_telemetry_exporters.py +0 -0
  60. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_telemetry_types.py +0 -0
  61. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_trace_helpers.py +0 -0
  62. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_trigger_metadata.py +0 -0
  63. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_utils.py +0 -0
  64. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_worker_metadata.py +0 -0
  65. {iii_sdk-0.11.7.dev1 → iii_sdk-0.11.7.dev3}/tests/test_worker_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iii-sdk
3
- Version: 0.11.7.dev1
3
+ Version: 0.11.7.dev3
4
4
  Summary: III SDK for Python
5
5
  Project-URL: Homepage, https://github.com/iii-hq/iii
6
6
  Project-URL: Repository, https://github.com/iii-hq/iii
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "iii-sdk"
7
- version = "0.11.7.dev1"
7
+ version = "0.11.7.dev3"
8
8
  description = "III SDK for Python"
9
9
  authors = [{ name = "III" }]
10
10
  license = { text = "Apache-2.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. Currently emitted only by the ``merge`` op for
121
- # validation rejections. Field is omitted from the JSON wire when
122
- # empty. ``default_factory`` is used (not ``= []``) to keep
123
- # Pydantic's parameterized-Generic + default handling well-behaved
124
- # across Python versions.
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 operation for stream update."""
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
- path: str
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
+ )