iii-sdk 0.11.7.dev2__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.dev2 → iii_sdk-0.11.7.dev3}/PKG-INFO +1 -1
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/pyproject.toml +1 -1
- {iii_sdk-0.11.7.dev2 → 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.dev2 → 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.dev2 → iii_sdk-0.11.7.dev3}/src/iii/telemetry.py +6 -0
- {iii_sdk-0.11.7.dev2 → 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.dev2 → iii_sdk-0.11.7.dev3}/uv.lock +1078 -1078
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/.gitignore +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/README.md +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/channels.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/errors.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/format_utils.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/iii_constants.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/iii_types.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/logger.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/otel_worker_gauges.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/state.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/stream.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/telemetry_exporters.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/telemetry_types.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/triggers.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/types.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/utils.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/src/iii/worker_metrics.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/conftest.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_async_api.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_bridge.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_channel_close_delay.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_context_propagation.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_data_channels.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_errors.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_format_utils.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_healthcheck.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_hold_process.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_http_external_functions_integration.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_iii_registration_dedup.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_init_api.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_invocation_exception.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_logger_function_ids.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_logger_otel.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_middleware.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_pubsub.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_queue_integration.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_rbac_workers.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_register_function_args.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_state.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_stream_models.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_streams.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_streams_runtime_annotations.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_sync_api.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_telemetry.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_telemetry_exporters.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_telemetry_types.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_trace_helpers.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_trigger_metadata.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_utils.py +0 -0
- {iii_sdk-0.11.7.dev2 → iii_sdk-0.11.7.dev3}/tests/test_worker_metadata.py +0 -0
- {iii_sdk-0.11.7.dev2 → 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 {})
|
|
@@ -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
|
+
)
|