openbox-sdk-python 0.2.0__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.
- openbox_core/__init__.py +59 -0
- openbox_core/adapters/__init__.py +3 -0
- openbox_core/adapters/base.py +123 -0
- openbox_core/approvals.py +106 -0
- openbox_core/client.py +298 -0
- openbox_core/config.py +260 -0
- openbox_core/conformance/__init__.py +3 -0
- openbox_core/conformance/fake_core.py +169 -0
- openbox_core/conformance/hook_preflight.py +87 -0
- openbox_core/conformance/instrumentation.py +91 -0
- openbox_core/context.py +205 -0
- openbox_core/contracts/__init__.py +3 -0
- openbox_core/contracts/context.py +79 -0
- openbox_core/contracts/events.py +401 -0
- openbox_core/contracts/otel_spans.py +325 -0
- openbox_core/contracts/results.py +287 -0
- openbox_core/errors.py +287 -0
- openbox_core/gate.py +185 -0
- openbox_core/hooks/__init__.py +3 -0
- openbox_core/hooks/events.py +64 -0
- openbox_core/hooks/preflight.py +292 -0
- openbox_core/hooks/wrappers.py +105 -0
- openbox_core/identity.py +231 -0
- openbox_core/instrumentation/__init__.py +3 -0
- openbox_core/instrumentation/db.py +689 -0
- openbox_core/instrumentation/file.py +239 -0
- openbox_core/instrumentation/function.py +121 -0
- openbox_core/instrumentation/http.py +840 -0
- openbox_core/instrumentation/llm.py +3 -0
- openbox_core/instrumentation/manager.py +135 -0
- openbox_core/instrumentation/shared.py +27 -0
- openbox_core/otel/__init__.py +3 -0
- openbox_core/otel/propagation.py +45 -0
- openbox_core/otel/provider.py +35 -0
- openbox_core/otel/setup.py +36 -0
- openbox_core/otel/span_processor.py +62 -0
- openbox_core/otel/trace_context.py +71 -0
- openbox_core/py.typed +0 -0
- openbox_core/runtime.py +138 -0
- openbox_core/sdk_version.py +79 -0
- openbox_core/serialization.py +129 -0
- openbox_core/validation/__init__.py +3 -0
- openbox_core/validation/diagnostics.py +60 -0
- openbox_core/validation/event_rules.py +164 -0
- openbox_core/validation/registry.py +31 -0
- openbox_core/validation/span_normalization.py +107 -0
- openbox_core/wire/__init__.py +3 -0
- openbox_core/wire/core_span.py +130 -0
- openbox_core/wire/evaluate_payload.py +56 -0
- openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
- openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
- openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""InstrumentationManager — idempotent install/uninstall of all wrappers.
|
|
2
|
+
|
|
3
|
+
Owned by OpenBoxRuntime. Install order matters:
|
|
4
|
+
|
|
5
|
+
1. OTel provider + passive span processor (``install_opentelemetry`` toggle)
|
|
6
|
+
2. cross-thread ContextVar executor patch (best-effort; needs a running loop)
|
|
7
|
+
3. publish the HookRuntime to the shared instrumentation state
|
|
8
|
+
4. HTTP / DB / file wrappers per config toggles (each deferral is LOGGED)
|
|
9
|
+
|
|
10
|
+
The evaluate call must never instrument itself: the ignored-URL set always
|
|
11
|
+
contains the configured ``api_url``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from ..otel.propagation import install_context_propagating_executor
|
|
20
|
+
from ..otel.setup import install_opentelemetry, shutdown_opentelemetry
|
|
21
|
+
from . import db as db_instrumentation
|
|
22
|
+
from . import file as file_instrumentation
|
|
23
|
+
from . import http as http_instrumentation
|
|
24
|
+
from .shared import get_hook_runtime, set_hook_runtime
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ..runtime import OpenBoxRuntime
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
__all__ = ["InstrumentationManager"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InstrumentationManager:
|
|
35
|
+
"""Install/uninstall lifecycle with per-target guard flags."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, runtime: OpenBoxRuntime, *, extra_ignored_urls: set[str] | None = None):
|
|
38
|
+
self._runtime = runtime
|
|
39
|
+
self._installed = False
|
|
40
|
+
self._provider: Any = None
|
|
41
|
+
self._installed_targets: list[str] = []
|
|
42
|
+
self._extra_ignored_urls = set(extra_ignored_urls or ())
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def installed_targets(self) -> list[str]:
|
|
46
|
+
return list(self._installed_targets)
|
|
47
|
+
|
|
48
|
+
def install(self) -> None:
|
|
49
|
+
"""Idempotent — a second install() is a no-op."""
|
|
50
|
+
if self._installed:
|
|
51
|
+
return
|
|
52
|
+
config = self._runtime.config
|
|
53
|
+
instrumentation = config.instrumentation
|
|
54
|
+
|
|
55
|
+
if instrumentation.install_opentelemetry:
|
|
56
|
+
self._provider, _ = install_opentelemetry(self._runtime.context_store)
|
|
57
|
+
|
|
58
|
+
# ContextVars → executor threads (no running loop ⇒ skipped, logged).
|
|
59
|
+
if not install_context_propagating_executor():
|
|
60
|
+
logger.info("executor ContextVar patch deferred (no running event loop)")
|
|
61
|
+
|
|
62
|
+
# Self-instrumentation guard: never govern our own evaluate calls.
|
|
63
|
+
http_instrumentation.set_ignored_url_prefixes(
|
|
64
|
+
{config.api_url, *self._extra_ignored_urls}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
from ..hooks.preflight import HookRuntime
|
|
68
|
+
|
|
69
|
+
set_hook_runtime(HookRuntime(self._runtime))
|
|
70
|
+
|
|
71
|
+
if instrumentation.http_enabled:
|
|
72
|
+
if http_instrumentation.install_requests():
|
|
73
|
+
self._installed_targets.append("requests")
|
|
74
|
+
if http_instrumentation.install_httpx():
|
|
75
|
+
self._installed_targets.append("httpx")
|
|
76
|
+
# Body capture patches Client.send AFTER OTel instrumentation so
|
|
77
|
+
# the captured original send already carries the request hook.
|
|
78
|
+
if http_instrumentation.install_httpx_body_capture():
|
|
79
|
+
self._installed_targets.append("httpx_body_capture")
|
|
80
|
+
if http_instrumentation.install_urllib3():
|
|
81
|
+
self._installed_targets.append("urllib3")
|
|
82
|
+
if http_instrumentation.install_urllib():
|
|
83
|
+
self._installed_targets.append("urllib")
|
|
84
|
+
else:
|
|
85
|
+
logger.info("HTTP instrumentation disabled by config")
|
|
86
|
+
|
|
87
|
+
if instrumentation.db_enabled:
|
|
88
|
+
if db_instrumentation.install_sqlalchemy():
|
|
89
|
+
self._installed_targets.append("sqlalchemy")
|
|
90
|
+
if db_instrumentation.install_dbapi():
|
|
91
|
+
self._installed_targets.append("dbapi")
|
|
92
|
+
if db_instrumentation.install_asyncpg():
|
|
93
|
+
self._installed_targets.append("asyncpg")
|
|
94
|
+
if db_instrumentation.install_redis():
|
|
95
|
+
self._installed_targets.append("redis")
|
|
96
|
+
if db_instrumentation.install_pymongo():
|
|
97
|
+
self._installed_targets.append("pymongo")
|
|
98
|
+
else:
|
|
99
|
+
logger.info("DB instrumentation disabled by config")
|
|
100
|
+
|
|
101
|
+
if instrumentation.file_enabled:
|
|
102
|
+
if file_instrumentation.install_file_io():
|
|
103
|
+
self._installed_targets.append("file")
|
|
104
|
+
else:
|
|
105
|
+
logger.info("file instrumentation disabled by config (opt-in)")
|
|
106
|
+
|
|
107
|
+
# Function instrumentation is decorator-based (@governed) — nothing to
|
|
108
|
+
# patch; it activates through the published hook runtime.
|
|
109
|
+
logger.info(f"OpenBox instrumentation installed: {self._installed_targets}")
|
|
110
|
+
self._installed = True
|
|
111
|
+
|
|
112
|
+
def uninstall(self) -> None:
|
|
113
|
+
"""Restore every original and flush OTel (idempotent, safe shutdown)."""
|
|
114
|
+
if not self._installed:
|
|
115
|
+
return
|
|
116
|
+
file_instrumentation.uninstall_file_io()
|
|
117
|
+
db_instrumentation.uninstall_pymongo()
|
|
118
|
+
db_instrumentation.uninstall_redis()
|
|
119
|
+
db_instrumentation.uninstall_asyncpg()
|
|
120
|
+
db_instrumentation.uninstall_dbapi()
|
|
121
|
+
db_instrumentation.uninstall_sqlalchemy()
|
|
122
|
+
# Reverse install order: unwind the send patch before OTel httpx.
|
|
123
|
+
http_instrumentation.uninstall_urllib()
|
|
124
|
+
http_instrumentation.uninstall_urllib3()
|
|
125
|
+
http_instrumentation.uninstall_httpx_body_capture()
|
|
126
|
+
http_instrumentation.uninstall_httpx()
|
|
127
|
+
http_instrumentation.uninstall_requests()
|
|
128
|
+
if get_hook_runtime() is not None:
|
|
129
|
+
set_hook_runtime(None)
|
|
130
|
+
if self._provider is not None:
|
|
131
|
+
shutdown_opentelemetry(self._provider)
|
|
132
|
+
self._provider = None
|
|
133
|
+
self._installed_targets.clear()
|
|
134
|
+
self._installed = False
|
|
135
|
+
logger.info("OpenBox instrumentation uninstalled")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Shared instrumentation state — the active HookRuntime.
|
|
2
|
+
|
|
3
|
+
Patched call sites (module-level hooks, builtins patches) can't hold instance
|
|
4
|
+
references, so the manager publishes the active runtime here on install and
|
|
5
|
+
clears it on uninstall. One active runtime per process (matching the
|
|
6
|
+
one-global-config model of the existing SDKs).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..hooks.preflight import HookRuntime
|
|
15
|
+
|
|
16
|
+
__all__ = ["set_hook_runtime", "get_hook_runtime"]
|
|
17
|
+
|
|
18
|
+
_hook_runtime: HookRuntime | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_hook_runtime(runtime: HookRuntime | None) -> None:
|
|
22
|
+
global _hook_runtime
|
|
23
|
+
_hook_runtime = runtime
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_hook_runtime() -> HookRuntime | None:
|
|
27
|
+
return _hook_runtime
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Cross-thread ContextVar propagation for run_in_executor threads.
|
|
2
|
+
|
|
3
|
+
Python < 3.12 does NOT copy ContextVars into default-executor threads: the
|
|
4
|
+
HTTP/DB instrumentors then create spans with trace_id=0 in the worker thread
|
|
5
|
+
and the hook runtime can't find the bound ActivityContext. Installing a
|
|
6
|
+
context-propagating executor fixes both on Python 3.11, the pinned floor.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import concurrent.futures
|
|
12
|
+
import contextvars
|
|
13
|
+
import functools
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
__all__ = ["ContextPropagatingExecutor", "install_context_propagating_executor"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ContextPropagatingExecutor(concurrent.futures.ThreadPoolExecutor):
|
|
22
|
+
"""ThreadPoolExecutor that copies ContextVars into spawned threads."""
|
|
23
|
+
|
|
24
|
+
def submit(self, fn, /, *args, **kwargs):
|
|
25
|
+
ctx = contextvars.copy_context()
|
|
26
|
+
return super().submit(ctx.run, functools.partial(fn, *args, **kwargs))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install_context_propagating_executor(max_workers: int = 32) -> bool:
|
|
30
|
+
"""Install a ContextVar-propagating default executor on the running loop.
|
|
31
|
+
|
|
32
|
+
Only the DEFAULT executor (``run_in_executor(None, ...)``) is patched;
|
|
33
|
+
explicit user executors are untouched. Returns True when installed,
|
|
34
|
+
False when no loop is running (callers may retry from async context).
|
|
35
|
+
"""
|
|
36
|
+
import asyncio
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
loop = asyncio.get_running_loop()
|
|
40
|
+
except RuntimeError:
|
|
41
|
+
logger.debug("No running event loop — executor patch skipped")
|
|
42
|
+
return False
|
|
43
|
+
loop.set_default_executor(ContextPropagatingExecutor(max_workers=max_workers))
|
|
44
|
+
logger.info("Installed context-propagating default executor")
|
|
45
|
+
return True
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Create-or-reuse TracerProvider — never clobber a user-installed provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
__all__ = ["get_or_create_tracer_provider", "get_tracer"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_or_create_tracer_provider() -> Any:
|
|
14
|
+
"""Return the process TracerProvider, creating one only if none exists.
|
|
15
|
+
|
|
16
|
+
Attaches to an existing SDK provider (user-installed providers are
|
|
17
|
+
reused, never replaced); only the no-op default is upgraded to a real
|
|
18
|
+
``opentelemetry.sdk.trace.TracerProvider``.
|
|
19
|
+
"""
|
|
20
|
+
from opentelemetry import trace
|
|
21
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
22
|
+
|
|
23
|
+
provider = trace.get_tracer_provider()
|
|
24
|
+
if not isinstance(provider, TracerProvider):
|
|
25
|
+
provider = TracerProvider()
|
|
26
|
+
trace.set_tracer_provider(provider)
|
|
27
|
+
logger.info("Created OpenBox TracerProvider (no SDK provider was set)")
|
|
28
|
+
return provider
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_tracer(name: str = "openbox_core") -> Any:
|
|
32
|
+
"""Tracer for spans the SDK creates itself (db/file/function wrappers)."""
|
|
33
|
+
from opentelemetry import trace
|
|
34
|
+
|
|
35
|
+
return trace.get_tracer(name)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""install_opentelemetry()/shutdown_opentelemetry() — OTel lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..context import ContextStore
|
|
9
|
+
from .provider import get_or_create_tracer_provider
|
|
10
|
+
from .span_processor import OpenBoxSpanProcessor
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
__all__ = ["install_opentelemetry", "shutdown_opentelemetry"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def install_opentelemetry(context_store: ContextStore) -> tuple[Any, OpenBoxSpanProcessor]:
|
|
18
|
+
"""Attach the passive OpenBox span processor to the process provider.
|
|
19
|
+
|
|
20
|
+
Reuses an existing user provider; returns (provider, processor) so the
|
|
21
|
+
caller can flush/detach on shutdown.
|
|
22
|
+
"""
|
|
23
|
+
provider = get_or_create_tracer_provider()
|
|
24
|
+
processor = OpenBoxSpanProcessor(context_store)
|
|
25
|
+
provider.add_span_processor(processor)
|
|
26
|
+
logger.info("Registered OpenBoxSpanProcessor with the TracerProvider")
|
|
27
|
+
return provider, processor
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def shutdown_opentelemetry(provider: Any) -> None:
|
|
31
|
+
"""Flush pending spans. The provider itself is left running — it may be
|
|
32
|
+
user-owned; the SDK only ever ADDS a processor, so it only flushes."""
|
|
33
|
+
try:
|
|
34
|
+
provider.force_flush(timeout_millis=5000)
|
|
35
|
+
except Exception:
|
|
36
|
+
logger.debug("TracerProvider force_flush failed", exc_info=True)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""OpenBoxSpanProcessor — a PASSIVE SpanProcessor.
|
|
2
|
+
|
|
3
|
+
It registers trace -> ActivityContext correlations so hook code can resolve
|
|
4
|
+
the bound context from any child span, and nothing else. It MUST NOT perform
|
|
5
|
+
preflight blocking: ``on_end`` fires after the operation already ran, so hard
|
|
6
|
+
blocking lives exclusively in the wrapper call path (hooks/wrappers.py).
|
|
7
|
+
Abort/halt registries live in the ContextStore/runtime — not here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from opentelemetry.sdk.trace import SpanProcessor
|
|
16
|
+
|
|
17
|
+
from ..context import ContextStore
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
__all__ = ["OpenBoxSpanProcessor"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OpenBoxSpanProcessor(SpanProcessor):
|
|
25
|
+
"""Passive correlation processor bound to a ContextStore."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, context_store: ContextStore):
|
|
28
|
+
self._store = context_store
|
|
29
|
+
|
|
30
|
+
def on_start(self, span: Any, parent_context: Any = None) -> None:
|
|
31
|
+
"""Register the span's trace to the CURRENTLY BOUND context (if any).
|
|
32
|
+
|
|
33
|
+
Frameworks register the activity root trace explicitly via
|
|
34
|
+
``activity_scope``; this passive hook additionally catches spans whose
|
|
35
|
+
traces were minted after binding. No bound context ⇒ no-op.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
ctx = self._store.current_activity_context()
|
|
39
|
+
if ctx is None:
|
|
40
|
+
return
|
|
41
|
+
span_context = span.get_span_context()
|
|
42
|
+
trace_id = getattr(span_context, "trace_id", None)
|
|
43
|
+
if isinstance(trace_id, int) and trace_id != 0:
|
|
44
|
+
if self._store.context_for_trace(trace_id) is None:
|
|
45
|
+
self._store.register_trace(trace_id, ctx)
|
|
46
|
+
except Exception: # correlation must never break user spans
|
|
47
|
+
logger.debug("OpenBoxSpanProcessor.on_start correlation failed", exc_info=True)
|
|
48
|
+
|
|
49
|
+
def on_end(self, span: Any) -> None:
|
|
50
|
+
"""PASSIVE by contract — never evaluates, never blocks, never raises.
|
|
51
|
+
|
|
52
|
+
``on_end`` fires after the operation completed; a verdict here could
|
|
53
|
+
not stop anything. Hard preflight blocking happens in the wrapper
|
|
54
|
+
call path before the real operation.
|
|
55
|
+
"""
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def shutdown(self) -> None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
62
|
+
return True
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Span-context extraction and hex-id formatting for wire payloads.
|
|
2
|
+
|
|
3
|
+
Two representations, never confused:
|
|
4
|
+
- INTERNAL: raw OTel integers (trace correlation keys — see context.py).
|
|
5
|
+
- WIRE: lowercase hex strings — ``span_id`` 16 chars, ``trace_id`` 32 chars,
|
|
6
|
+
``parent_span_id`` 16 chars. Raw integers must NEVER reach the wire.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"format_span_id",
|
|
15
|
+
"format_trace_id",
|
|
16
|
+
"extract_span_context",
|
|
17
|
+
"raw_trace_id",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_span_id(span_id: int) -> str:
|
|
22
|
+
"""16-char lowercase hex."""
|
|
23
|
+
return format(span_id, "016x")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_trace_id(trace_id: int) -> str:
|
|
27
|
+
"""32-char lowercase hex."""
|
|
28
|
+
return format(trace_id, "032x")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _span_context_of(span: Any) -> Any:
|
|
32
|
+
if hasattr(span, "get_span_context"):
|
|
33
|
+
try:
|
|
34
|
+
return span.get_span_context()
|
|
35
|
+
except Exception:
|
|
36
|
+
return None
|
|
37
|
+
return getattr(span, "context", None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_span_context(span: Any) -> tuple[str, str, str | None]:
|
|
41
|
+
"""(span_id_hex16, trace_id_hex32, parent_span_id_hex16 | None).
|
|
42
|
+
|
|
43
|
+
Handles NonRecordingSpan, mocks, and missing attributes safely — degraded
|
|
44
|
+
ids become all-zero hex, never an exception.
|
|
45
|
+
"""
|
|
46
|
+
span_context = _span_context_of(span)
|
|
47
|
+
try:
|
|
48
|
+
span_id_value = getattr(span_context, "span_id", None)
|
|
49
|
+
span_id = format_span_id(span_id_value) if isinstance(span_id_value, int) else "0" * 16
|
|
50
|
+
except (AttributeError, TypeError):
|
|
51
|
+
span_id = "0" * 16
|
|
52
|
+
try:
|
|
53
|
+
trace_id_value = getattr(span_context, "trace_id", None)
|
|
54
|
+
trace_id = format_trace_id(trace_id_value) if isinstance(trace_id_value, int) else "0" * 32
|
|
55
|
+
except (AttributeError, TypeError):
|
|
56
|
+
trace_id = "0" * 32
|
|
57
|
+
|
|
58
|
+
parent_span_id = None
|
|
59
|
+
parent = getattr(span, "parent", None)
|
|
60
|
+
parent_id_value = getattr(parent, "span_id", None) if parent is not None else None
|
|
61
|
+
if isinstance(parent_id_value, int):
|
|
62
|
+
parent_span_id = format_span_id(parent_id_value)
|
|
63
|
+
|
|
64
|
+
return span_id, trace_id, parent_span_id
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def raw_trace_id(span: Any) -> int | None:
|
|
68
|
+
"""The raw integer trace id for context-store lookups (or None)."""
|
|
69
|
+
span_context = _span_context_of(span)
|
|
70
|
+
trace_id = getattr(span_context, "trace_id", None)
|
|
71
|
+
return trace_id if isinstance(trace_id, int) else None
|
openbox_core/py.typed
ADDED
|
File without changes
|
openbox_core/runtime.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""OpenBoxRuntime — owns config, adapter, gate, client, context store, and
|
|
2
|
+
(from the instrumentation phase) the instrumentation manager.
|
|
3
|
+
|
|
4
|
+
The runtime is the composition root for non-sandbox code:
|
|
5
|
+
|
|
6
|
+
config = OpenBoxConfig.resolve(...)
|
|
7
|
+
runtime = OpenBoxRuntime(config, adapter=MyFrameworkAdapter())
|
|
8
|
+
runtime.install_instrumentation()
|
|
9
|
+
...
|
|
10
|
+
runtime.close()
|
|
11
|
+
|
|
12
|
+
Lifecycle helpers evaluate through the strict gate and delegate native
|
|
13
|
+
enforcement (block/halt/approval) to the adapter callbacks.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .adapters.base import CoreAdapter, FrameworkAdapter
|
|
21
|
+
from .client import EvaluationClient
|
|
22
|
+
from .config import OpenBoxConfig
|
|
23
|
+
from .context import ContextStore, default_context_store
|
|
24
|
+
from .contracts.events import EventEnvelope
|
|
25
|
+
from .contracts.results import EvaluationResult, Verdict
|
|
26
|
+
from .errors import GuardrailsValidationError
|
|
27
|
+
from .gate import GovernanceGate
|
|
28
|
+
|
|
29
|
+
__all__ = ["OpenBoxRuntime"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_payload_builder(config: OpenBoxConfig) -> Any:
|
|
33
|
+
"""Wire the hook evaluate-body assembler (the single body-shape owner),
|
|
34
|
+
binding the runtime's privacy config into the gate's one-argument seam."""
|
|
35
|
+
from .wire.evaluate_payload import make_payload_builder
|
|
36
|
+
|
|
37
|
+
return make_payload_builder(config.privacy)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OpenBoxRuntime:
|
|
41
|
+
"""Composition root wiring config → identity → client → gate → adapter."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
config: OpenBoxConfig,
|
|
46
|
+
adapter: FrameworkAdapter | None = None,
|
|
47
|
+
*,
|
|
48
|
+
client: EvaluationClient | None = None,
|
|
49
|
+
context_store: ContextStore | None = None,
|
|
50
|
+
payload_builder: Any = None,
|
|
51
|
+
):
|
|
52
|
+
self.config = config
|
|
53
|
+
self.adapter: FrameworkAdapter = adapter if adapter is not None else CoreAdapter()
|
|
54
|
+
self.context_store = context_store if context_store is not None else default_context_store()
|
|
55
|
+
self.client = client if client is not None else EvaluationClient(
|
|
56
|
+
config.api_url,
|
|
57
|
+
config.api_key,
|
|
58
|
+
timeout_seconds=config.timeout_seconds,
|
|
59
|
+
on_api_error=config.on_api_error,
|
|
60
|
+
identity=config.load_identity(),
|
|
61
|
+
sdk_version=config.sdk_version,
|
|
62
|
+
sdk_engine=config.sdk_engine,
|
|
63
|
+
sdk_language=config.sdk_language,
|
|
64
|
+
)
|
|
65
|
+
self.gate = GovernanceGate(
|
|
66
|
+
self.client,
|
|
67
|
+
config,
|
|
68
|
+
payload_builder=payload_builder if payload_builder is not None else _default_payload_builder(config),
|
|
69
|
+
)
|
|
70
|
+
self._instrumentation_manager: Any = None # set by install_instrumentation
|
|
71
|
+
|
|
72
|
+
# ── Instrumentation lifecycle (real body lands with the manager) ──────
|
|
73
|
+
|
|
74
|
+
def install_instrumentation(self) -> None:
|
|
75
|
+
"""Install generic instrumentation per InstrumentationConfig."""
|
|
76
|
+
if not self.config.instrumentation.enabled:
|
|
77
|
+
return
|
|
78
|
+
if self._instrumentation_manager is None:
|
|
79
|
+
from .instrumentation.manager import InstrumentationManager
|
|
80
|
+
|
|
81
|
+
self._instrumentation_manager = InstrumentationManager(self)
|
|
82
|
+
self._instrumentation_manager.install()
|
|
83
|
+
|
|
84
|
+
def uninstall_instrumentation(self) -> None:
|
|
85
|
+
"""Uninstall instrumentation and restore originals (idempotent)."""
|
|
86
|
+
if self._instrumentation_manager is not None:
|
|
87
|
+
self._instrumentation_manager.uninstall()
|
|
88
|
+
|
|
89
|
+
# ── Lifecycle evaluation helpers ──────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def evaluate_lifecycle(self, event: EventEnvelope) -> EvaluationResult:
|
|
92
|
+
"""Evaluate + enforce a lifecycle event (sync).
|
|
93
|
+
|
|
94
|
+
BLOCK/HALT delegate to ``adapter.raise_lifecycle_blocked``; a
|
|
95
|
+
guardrails failure raises before any approval flow. REQUIRE_APPROVAL
|
|
96
|
+
is returned to the caller on the sync path (approval flows are async —
|
|
97
|
+
use :meth:`aevaluate_lifecycle` to drive the adapter's approval flow).
|
|
98
|
+
"""
|
|
99
|
+
result = self.gate.evaluate(event)
|
|
100
|
+
return self._enforce_lifecycle(result, drive_approval=False)
|
|
101
|
+
|
|
102
|
+
async def aevaluate_lifecycle(self, event: EventEnvelope) -> EvaluationResult:
|
|
103
|
+
"""Async evaluate + enforce, including the adapter approval flow."""
|
|
104
|
+
result = await self.gate.aevaluate(event)
|
|
105
|
+
if result.verdict.requires_approval():
|
|
106
|
+
self._check_guardrails(result)
|
|
107
|
+
await self.adapter.handle_approval(result)
|
|
108
|
+
return result
|
|
109
|
+
return self._enforce_lifecycle(result, drive_approval=False)
|
|
110
|
+
|
|
111
|
+
def _enforce_lifecycle(self, result: EvaluationResult, *, drive_approval: bool) -> EvaluationResult:
|
|
112
|
+
if result.verdict.should_stop():
|
|
113
|
+
if result.verdict is Verdict.HALT:
|
|
114
|
+
self.context_store.request_halt()
|
|
115
|
+
self.adapter.raise_lifecycle_blocked(result)
|
|
116
|
+
self._check_guardrails(result)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _check_guardrails(result: EvaluationResult) -> None:
|
|
121
|
+
# Guardrails failure outranks approval so it is never swallowed by HITL.
|
|
122
|
+
if result.guardrails and not result.guardrails.validation_passed:
|
|
123
|
+
reasons = result.guardrails.get_reason_strings()
|
|
124
|
+
raise GuardrailsValidationError(reasons or ["Guardrails validation failed"])
|
|
125
|
+
|
|
126
|
+
# ── Shutdown ──────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
"""Uninstall instrumentation, clear correlation state, close transports."""
|
|
130
|
+
self.uninstall_instrumentation()
|
|
131
|
+
self.context_store.clear()
|
|
132
|
+
self.client.close()
|
|
133
|
+
|
|
134
|
+
async def aclose(self) -> None:
|
|
135
|
+
"""Async :meth:`close` (also closes the async transport)."""
|
|
136
|
+
self.uninstall_instrumentation()
|
|
137
|
+
self.context_store.clear()
|
|
138
|
+
await self.client.aclose()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""SDK identifier formatting for OpenBox request headers.
|
|
2
|
+
|
|
3
|
+
The wire value is intentionally framework-branded rather than just a package
|
|
4
|
+
version so Core can distinguish SDK families:
|
|
5
|
+
|
|
6
|
+
openbox-{engine}-{language}-v{version}
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DEFAULT_SDK_ENGINE",
|
|
15
|
+
"DEFAULT_SDK_LANGUAGE",
|
|
16
|
+
"SDK_IDENTIFIER_PATTERN",
|
|
17
|
+
"build_sdk_identifier",
|
|
18
|
+
"normalize_sdk_version",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
DEFAULT_SDK_ENGINE = "base"
|
|
22
|
+
DEFAULT_SDK_LANGUAGE = "python"
|
|
23
|
+
|
|
24
|
+
_SLUG_PART_RE = re.compile(r"[^a-z0-9]+")
|
|
25
|
+
_VERSION_RE = re.compile(r"^\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$")
|
|
26
|
+
|
|
27
|
+
SDK_IDENTIFIER_PATTERN = re.compile(
|
|
28
|
+
r"^openbox-[a-z0-9]+(?:-[a-z0-9]+)*-[a-z0-9]+-v"
|
|
29
|
+
r"\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _slug(value: str, field_name: str) -> str:
|
|
34
|
+
text = str(value).strip().lower()
|
|
35
|
+
slug = _SLUG_PART_RE.sub("-", text).strip("-")
|
|
36
|
+
if not slug:
|
|
37
|
+
raise ValueError(f"{field_name} must not be empty")
|
|
38
|
+
return slug
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_sdk_version(version: str) -> str:
|
|
42
|
+
"""Return a canonical ``v``-prefixed SDK version component."""
|
|
43
|
+
|
|
44
|
+
raw = str(version).strip()
|
|
45
|
+
if raw.startswith(("v", "V")):
|
|
46
|
+
raw = raw[1:]
|
|
47
|
+
if not _VERSION_RE.fullmatch(raw):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"sdk version must look like '1.1' or '1.2.3' "
|
|
50
|
+
f"(optionally with a suffix), got {version!r}"
|
|
51
|
+
)
|
|
52
|
+
return f"v{raw}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_sdk_identifier(
|
|
56
|
+
*,
|
|
57
|
+
engine: str = DEFAULT_SDK_ENGINE,
|
|
58
|
+
language: str = DEFAULT_SDK_LANGUAGE,
|
|
59
|
+
version: str | None = None,
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Build ``openbox-{engine}-{language}-v{version}``.
|
|
62
|
+
|
|
63
|
+
``version`` may be a raw package version (``1.2.3``), a ``v``-prefixed
|
|
64
|
+
version (``v1.2.3``), or an already formatted OpenBox SDK identifier.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
if version is None:
|
|
68
|
+
from . import __version__ as version
|
|
69
|
+
|
|
70
|
+
raw = str(version).strip()
|
|
71
|
+
if raw.startswith("openbox-"):
|
|
72
|
+
if not SDK_IDENTIFIER_PATTERN.fullmatch(raw):
|
|
73
|
+
raise ValueError(f"invalid OpenBox SDK identifier: {version!r}")
|
|
74
|
+
return raw
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
f"openbox-{_slug(engine, 'sdk engine')}-"
|
|
78
|
+
f"{_slug(language, 'sdk language')}-{normalize_sdk_version(raw)}"
|
|
79
|
+
)
|