rippletide-python 0.1.0__tar.gz → 0.2.0__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.
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/PKG-INFO +69 -11
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/README.md +66 -10
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/pyproject.toml +2 -1
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/rippletide/client.py +76 -113
- rippletide_python-0.2.0/rippletide/telemetry.py +241 -0
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/rippletide_python.egg-info/PKG-INFO +69 -11
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/rippletide_python.egg-info/SOURCES.txt +2 -0
- rippletide_python-0.2.0/rippletide_python.egg-info/requires.txt +2 -0
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/tests/test_client.py +111 -4
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/rippletide/__init__.py +0 -0
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/rippletide_python.egg-info/dependency_links.txt +0 -0
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/rippletide_python.egg-info/top_level.txt +0 -0
- {rippletide_python-0.1.0 → rippletide_python-0.2.0}/setup.cfg +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rippletide-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Monitor-first Python SDK for Rippletide-connected agents
|
|
5
5
|
License: UNLICENSED
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: opentelemetry-sdk==1.40.0
|
|
9
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-common==1.40.0
|
|
8
10
|
|
|
9
11
|
# Rippletide Python
|
|
10
12
|
|
|
@@ -12,6 +14,41 @@ Description-Content-Type: text/markdown
|
|
|
12
14
|
taking ownership of its model traffic. It has no provider dependencies: wrap a
|
|
13
15
|
hand-written provider or internal HTTP call at the existing model seam.
|
|
14
16
|
|
|
17
|
+
## Install and connect
|
|
18
|
+
|
|
19
|
+
The native OTel release candidate is `rippletide-python==0.2.0`. It requires
|
|
20
|
+
the platform's OTLP ingestion/readers and does not provide V1 trace compatibility.
|
|
21
|
+
Until it is published, validate it by installing a locally built wheel; do not
|
|
22
|
+
assume the unversioned registry install below contains candidate changes.
|
|
23
|
+
|
|
24
|
+
Requires Python 3.10 or later. From the existing agent repository, use its Python
|
|
25
|
+
environment, or create one if it has none:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python3 -m venv .venv
|
|
29
|
+
source .venv/bin/activate
|
|
30
|
+
python -m pip install rippletide-python
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
On Windows, activate with `.venv\Scripts\Activate.ps1` in PowerShell instead.
|
|
34
|
+
Keep the dependency in the project's requirements or package-manager manifest.
|
|
35
|
+
|
|
36
|
+
In Rippletide, choose **Connect an agent → Agent (Python)**. Save the generated
|
|
37
|
+
credentials in the repository's git-ignored `.env`, then apply the generated
|
|
38
|
+
prompt with your coding agent in that repository. The CLI also supports
|
|
39
|
+
`rippletide connect --kind agent --language python --print-prompt`.
|
|
40
|
+
|
|
41
|
+
Run a real turn that uses a tool and delivers a response. Confirm **Runtime
|
|
42
|
+
verified (5/5)** in Fleet and the run's tool calls and **Delivered** outcome in
|
|
43
|
+
Activity. Copying a prompt or storing credentials alone does not prove a
|
|
44
|
+
connection. The SDK supports synchronous call sites. Async execution and
|
|
45
|
+
streaming are not supported.
|
|
46
|
+
|
|
47
|
+
## Instrument existing call sites
|
|
48
|
+
|
|
49
|
+
The following fragment belongs inside the existing request handler; reuse the
|
|
50
|
+
agent's own provider, messages, tools, context, and response sink.
|
|
51
|
+
|
|
15
52
|
```python
|
|
16
53
|
from rippletide import Rippletide, load_rippletide_env
|
|
17
54
|
|
|
@@ -48,18 +85,36 @@ connection takes precedence over those legacy values; explicit constructor
|
|
|
48
85
|
arguments take precedence over either format. Never use a Platform key in the
|
|
49
86
|
agent or commit the `.env`.
|
|
50
87
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
88
|
+
## Policy failures and capture
|
|
89
|
+
|
|
90
|
+
The client starts disabled when no Connection key is present. For
|
|
91
|
+
`guard_tool_call()` and `deliver_response()`:
|
|
92
|
+
|
|
93
|
+
- Network failures, 5xx responses, rate limits, and malformed or interrupted HTTP
|
|
94
|
+
responses fail open: the protected callback can still run.
|
|
95
|
+
- An enforce-mode `BLOCK` raises `RippletidePolicyBlockedError` before the
|
|
96
|
+
protected callback runs. An observe-only `WOULD_BLOCK` permits execution.
|
|
97
|
+
- HTTP `400`, `401`, `403`, or `404` from the policy decision endpoint raises
|
|
98
|
+
`RippletidePolicyDecisionError` before the callback runs. These request,
|
|
99
|
+
authentication, authorization, or routing failures are not swallowed as
|
|
100
|
+
telemetry outages.
|
|
101
|
+
|
|
102
|
+
Result filtering has its own fallback rules, described below. Application input
|
|
103
|
+
validation and exceptions from the agent's own callbacks can also propagate.
|
|
104
|
+
|
|
105
|
+
Runtime instrumentation uses native OpenTelemetry spans and start logs, exported as
|
|
106
|
+
OTLP/HTTP JSON to `/v1/traces` and `/v1/logs`. Providers are local to the client;
|
|
107
|
+
the SDK does not replace application providers. The only profile is `full`, but
|
|
108
|
+
content is omitted unless `Rippletide(capture_content=True)` is explicit. Captured
|
|
109
|
+
content is bounded and credential-redacted, including nested Python mappings
|
|
110
|
+
such as `UserDict` and `MappingProxyType`, not generally PII-filtered. The old
|
|
111
|
+
capture modes are unsupported. `runtime_traces=False` disables evidence export
|
|
112
|
+
without disabling policy checks. Always call `flush()` before a short-lived
|
|
113
|
+
worker exits, and `shutdown()` when the client is no longer needed.
|
|
59
114
|
|
|
60
115
|
Tool and response policy spans include the invocation ID; once a decision arrives, their end
|
|
61
116
|
events and the protected action spans include its decision ID, including in
|
|
62
|
-
|
|
117
|
+
the default content-withheld posture. These IDs correlate the action with its policy receipt.
|
|
63
118
|
|
|
64
119
|
Prompt inventory is recorded with its content withheld by default. Pass
|
|
65
120
|
`allow_content_export=True` to `register_prompts(...)` only when the prompt
|
|
@@ -70,7 +125,10 @@ owner has explicitly approved storing the prompt text in Rippletide.
|
|
|
70
125
|
For an eligible collection returned by a tool, declare its result contract and
|
|
71
126
|
pass `allow_result_export=True`. This evaluates the complete result transiently
|
|
72
127
|
at Rippletide; do this only with the owner's explicit approval. It is false by
|
|
73
|
-
default
|
|
128
|
+
default. Transient policy failures and malformed or mismatched plans forward
|
|
129
|
+
every item unchanged. A missing result endpoint (`404` with no error code or
|
|
130
|
+
`NOT_FOUND`) also uses this fallback. Other deterministic request or permission
|
|
131
|
+
rejections raise `RippletidePolicyDecisionError` before `apply` runs.
|
|
74
132
|
|
|
75
133
|
```python
|
|
76
134
|
rippletide.tools("catalog", TOOLS, results={
|
|
@@ -4,6 +4,41 @@
|
|
|
4
4
|
taking ownership of its model traffic. It has no provider dependencies: wrap a
|
|
5
5
|
hand-written provider or internal HTTP call at the existing model seam.
|
|
6
6
|
|
|
7
|
+
## Install and connect
|
|
8
|
+
|
|
9
|
+
The native OTel release candidate is `rippletide-python==0.2.0`. It requires
|
|
10
|
+
the platform's OTLP ingestion/readers and does not provide V1 trace compatibility.
|
|
11
|
+
Until it is published, validate it by installing a locally built wheel; do not
|
|
12
|
+
assume the unversioned registry install below contains candidate changes.
|
|
13
|
+
|
|
14
|
+
Requires Python 3.10 or later. From the existing agent repository, use its Python
|
|
15
|
+
environment, or create one if it has none:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3 -m venv .venv
|
|
19
|
+
source .venv/bin/activate
|
|
20
|
+
python -m pip install rippletide-python
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
On Windows, activate with `.venv\Scripts\Activate.ps1` in PowerShell instead.
|
|
24
|
+
Keep the dependency in the project's requirements or package-manager manifest.
|
|
25
|
+
|
|
26
|
+
In Rippletide, choose **Connect an agent → Agent (Python)**. Save the generated
|
|
27
|
+
credentials in the repository's git-ignored `.env`, then apply the generated
|
|
28
|
+
prompt with your coding agent in that repository. The CLI also supports
|
|
29
|
+
`rippletide connect --kind agent --language python --print-prompt`.
|
|
30
|
+
|
|
31
|
+
Run a real turn that uses a tool and delivers a response. Confirm **Runtime
|
|
32
|
+
verified (5/5)** in Fleet and the run's tool calls and **Delivered** outcome in
|
|
33
|
+
Activity. Copying a prompt or storing credentials alone does not prove a
|
|
34
|
+
connection. The SDK supports synchronous call sites. Async execution and
|
|
35
|
+
streaming are not supported.
|
|
36
|
+
|
|
37
|
+
## Instrument existing call sites
|
|
38
|
+
|
|
39
|
+
The following fragment belongs inside the existing request handler; reuse the
|
|
40
|
+
agent's own provider, messages, tools, context, and response sink.
|
|
41
|
+
|
|
7
42
|
```python
|
|
8
43
|
from rippletide import Rippletide, load_rippletide_env
|
|
9
44
|
|
|
@@ -40,18 +75,36 @@ connection takes precedence over those legacy values; explicit constructor
|
|
|
40
75
|
arguments take precedence over either format. Never use a Platform key in the
|
|
41
76
|
agent or commit the `.env`.
|
|
42
77
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
78
|
+
## Policy failures and capture
|
|
79
|
+
|
|
80
|
+
The client starts disabled when no Connection key is present. For
|
|
81
|
+
`guard_tool_call()` and `deliver_response()`:
|
|
82
|
+
|
|
83
|
+
- Network failures, 5xx responses, rate limits, and malformed or interrupted HTTP
|
|
84
|
+
responses fail open: the protected callback can still run.
|
|
85
|
+
- An enforce-mode `BLOCK` raises `RippletidePolicyBlockedError` before the
|
|
86
|
+
protected callback runs. An observe-only `WOULD_BLOCK` permits execution.
|
|
87
|
+
- HTTP `400`, `401`, `403`, or `404` from the policy decision endpoint raises
|
|
88
|
+
`RippletidePolicyDecisionError` before the callback runs. These request,
|
|
89
|
+
authentication, authorization, or routing failures are not swallowed as
|
|
90
|
+
telemetry outages.
|
|
91
|
+
|
|
92
|
+
Result filtering has its own fallback rules, described below. Application input
|
|
93
|
+
validation and exceptions from the agent's own callbacks can also propagate.
|
|
94
|
+
|
|
95
|
+
Runtime instrumentation uses native OpenTelemetry spans and start logs, exported as
|
|
96
|
+
OTLP/HTTP JSON to `/v1/traces` and `/v1/logs`. Providers are local to the client;
|
|
97
|
+
the SDK does not replace application providers. The only profile is `full`, but
|
|
98
|
+
content is omitted unless `Rippletide(capture_content=True)` is explicit. Captured
|
|
99
|
+
content is bounded and credential-redacted, including nested Python mappings
|
|
100
|
+
such as `UserDict` and `MappingProxyType`, not generally PII-filtered. The old
|
|
101
|
+
capture modes are unsupported. `runtime_traces=False` disables evidence export
|
|
102
|
+
without disabling policy checks. Always call `flush()` before a short-lived
|
|
103
|
+
worker exits, and `shutdown()` when the client is no longer needed.
|
|
51
104
|
|
|
52
105
|
Tool and response policy spans include the invocation ID; once a decision arrives, their end
|
|
53
106
|
events and the protected action spans include its decision ID, including in
|
|
54
|
-
|
|
107
|
+
the default content-withheld posture. These IDs correlate the action with its policy receipt.
|
|
55
108
|
|
|
56
109
|
Prompt inventory is recorded with its content withheld by default. Pass
|
|
57
110
|
`allow_content_export=True` to `register_prompts(...)` only when the prompt
|
|
@@ -62,7 +115,10 @@ owner has explicitly approved storing the prompt text in Rippletide.
|
|
|
62
115
|
For an eligible collection returned by a tool, declare its result contract and
|
|
63
116
|
pass `allow_result_export=True`. This evaluates the complete result transiently
|
|
64
117
|
at Rippletide; do this only with the owner's explicit approval. It is false by
|
|
65
|
-
default
|
|
118
|
+
default. Transient policy failures and malformed or mismatched plans forward
|
|
119
|
+
every item unchanged. A missing result endpoint (`404` with no error code or
|
|
120
|
+
`NOT_FOUND`) also uses this fallback. Other deterministic request or permission
|
|
121
|
+
rejections raise `RippletidePolicyDecisionError` before `apply` runs.
|
|
66
122
|
|
|
67
123
|
```python
|
|
68
124
|
rippletide.tools("catalog", TOOLS, results={
|
|
@@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rippletide-python"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Monitor-first Python SDK for Rippletide-connected agents"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["opentelemetry-sdk==1.40.0", "opentelemetry-exporter-otlp-proto-common==1.40.0"]
|
|
11
12
|
license = { text = "UNLICENSED" }
|
|
12
13
|
|
|
13
14
|
[tool.setuptools]
|
|
@@ -10,24 +10,22 @@ import json
|
|
|
10
10
|
import math
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
|
-
import secrets
|
|
14
13
|
import time
|
|
15
14
|
import uuid
|
|
16
15
|
from hashlib import sha256
|
|
17
16
|
from http.client import HTTPException
|
|
18
|
-
from contextlib import contextmanager
|
|
19
|
-
from contextvars import ContextVar
|
|
17
|
+
from contextlib import contextmanager, nullcontext
|
|
20
18
|
from datetime import datetime, timezone
|
|
21
19
|
from typing import Any, Callable, Iterator, Mapping, TypeVar
|
|
22
20
|
from urllib.error import HTTPError, URLError
|
|
23
21
|
from urllib.parse import quote, unquote, urlsplit
|
|
24
22
|
from urllib.request import Request, urlopen
|
|
23
|
+
from opentelemetry import trace
|
|
24
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
25
|
+
from .telemetry import Telemetry, capture_value, sanitized_text
|
|
25
26
|
|
|
26
27
|
T = TypeVar("T")
|
|
27
|
-
|
|
28
|
-
_TRACEPARENT = re.compile(r"^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$", re.I)
|
|
29
|
-
_SENSITIVE_KEY = re.compile(r"^(?:api[_-]?key|access[_-]?token|auth[_-]?token|authorization|client[_-]?secret|cookie|password|private[_-]?key|refresh[_-]?token|secret|session[_-]?token|set-cookie|x-api-key)$", re.I)
|
|
30
|
-
_INLINE_AUTH = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9+/_=.,:;~-]{8,}", re.I)
|
|
28
|
+
_UNSET = object()
|
|
31
29
|
_DEFAULT_BASE_URL = "https://agent-evalserver-production.up.railway.app"
|
|
32
30
|
_STAGING_BASE_URL = "https://agent-evalserver-staging.up.railway.app"
|
|
33
31
|
_SHA256 = re.compile(r"^[0-9a-f]{64}$", re.I)
|
|
@@ -38,10 +36,6 @@ def _now() -> str:
|
|
|
38
36
|
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
39
37
|
|
|
40
38
|
|
|
41
|
-
def _hex(size: int) -> str:
|
|
42
|
-
return secrets.token_hex(size)
|
|
43
|
-
|
|
44
|
-
|
|
45
39
|
def _trim_url(value: str) -> str:
|
|
46
40
|
return value.rstrip("/")
|
|
47
41
|
|
|
@@ -90,30 +84,6 @@ def load_rippletide_env(path: str) -> None:
|
|
|
90
84
|
pass
|
|
91
85
|
|
|
92
86
|
|
|
93
|
-
def _redact_text(value: str) -> str:
|
|
94
|
-
return _INLINE_AUTH.sub(r"\1 [REDACTED]", value)[:4000]
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _safe(value: Any, *, redacted: bool = False, key: str = "", depth: int = 0) -> Any:
|
|
98
|
-
if depth > 6 or key and _SENSITIVE_KEY.match(key):
|
|
99
|
-
return "[REDACTED]"
|
|
100
|
-
if value is None or isinstance(value, (bool, int, float)):
|
|
101
|
-
return "[REDACTED]" if redacted and value is not None else value
|
|
102
|
-
if isinstance(value, str):
|
|
103
|
-
return "[REDACTED]" if redacted else _redact_text(value)
|
|
104
|
-
if isinstance(value, Mapping):
|
|
105
|
-
return {str(k)[:128]: _safe(v, redacted=redacted, key=str(k), depth=depth + 1) for k, v in list(value.items())[:50]}
|
|
106
|
-
if isinstance(value, (list, tuple)):
|
|
107
|
-
return [_safe(item, redacted=redacted, key=key, depth=depth + 1) for item in value[:50]]
|
|
108
|
-
return "[REDACTED]" if redacted else _redact_text(str(value))
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _capture(value: Any, mode: str) -> Any | None:
|
|
112
|
-
if mode == "metadata":
|
|
113
|
-
return None
|
|
114
|
-
return _safe(value, redacted=mode == "redacted")
|
|
115
|
-
|
|
116
|
-
|
|
117
87
|
def _clone_policy_value(value: Any, label: str) -> Any:
|
|
118
88
|
"""Detach the exact JSON snapshot that policy evaluates and apply receives."""
|
|
119
89
|
try:
|
|
@@ -242,19 +212,26 @@ class RippletidePolicyDecisionError(RuntimeError):
|
|
|
242
212
|
class _Span:
|
|
243
213
|
def __init__(self, client: "Rippletide", kind: str, name: str, *, root: bool = False, traceparent: str | None = None, **options: Any) -> None:
|
|
244
214
|
self.client, self.kind, self.name = client, kind, name
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
self.
|
|
248
|
-
self.
|
|
249
|
-
self.parent_span_id = inbound.group(2).lower() if inbound else parent["span_id"] if parent else None
|
|
250
|
-
self.options, self.token, self.ended = options, None, False
|
|
215
|
+
self.root, self.traceparent = root, traceparent
|
|
216
|
+
self.options = {key: value for key, value in options.items() if value is not _UNSET}
|
|
217
|
+
self.ended = False
|
|
218
|
+
self.native = trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)
|
|
251
219
|
self.output: Any = None
|
|
252
220
|
self.output_set = False
|
|
253
221
|
self.end_attributes: dict[str, Any] = {}
|
|
254
222
|
|
|
255
223
|
def __enter__(self) -> "_Span":
|
|
256
|
-
|
|
257
|
-
|
|
224
|
+
try:
|
|
225
|
+
if not self.client.disabled and self.client.agent_id and self.client.runtime_traces:
|
|
226
|
+
if self.client._telemetry is None:
|
|
227
|
+
self.client._telemetry = Telemetry(self.client)
|
|
228
|
+
self.native = self.client._telemetry.start(self.kind, self.name, self.options, self.root, self.traceparent)
|
|
229
|
+
except Exception:
|
|
230
|
+
self.client._warn("Rippletide span capture unavailable; continuing normally")
|
|
231
|
+
self.trace_id = format(self.native.get_span_context().trace_id, "032x")
|
|
232
|
+
self.span_id = format(self.native.get_span_context().span_id, "016x")
|
|
233
|
+
self.scope = trace.use_span(self.native, end_on_exit=False, record_exception=False, set_status_on_exception=False) if self.native.is_recording() else nullcontext()
|
|
234
|
+
self.scope.__enter__()
|
|
258
235
|
return self
|
|
259
236
|
|
|
260
237
|
def set_output(self, output: Any, **attributes: Any) -> None:
|
|
@@ -262,39 +239,57 @@ class _Span:
|
|
|
262
239
|
self.end_attributes.update(attributes)
|
|
263
240
|
|
|
264
241
|
def __exit__(self, exc_type: Any, exc: BaseException | None, _traceback: Any) -> bool:
|
|
265
|
-
|
|
266
|
-
self.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
242
|
+
try:
|
|
243
|
+
if self.native.is_recording():
|
|
244
|
+
options = {**self.options, **({"output": self.output} if self.output_set else {})}
|
|
245
|
+
self.native.set_attributes(self.client._telemetry.attributes(self.kind, options, self.end_attributes))
|
|
246
|
+
outcome = "blocked" if isinstance(exc, RippletidePolicyBlockedError) else "error" if exc else "ok"
|
|
247
|
+
self.native.set_attribute("rippletide.outcome", outcome)
|
|
248
|
+
self.native.set_status(trace.StatusCode.ERROR if outcome == "error" else trace.StatusCode.UNSET)
|
|
249
|
+
if exc:
|
|
250
|
+
details = {"exception.type": type(exc).__name__}
|
|
251
|
+
if self.client.capture_content:
|
|
252
|
+
details["exception.message"] = sanitized_text(str(exc))[:4000]
|
|
253
|
+
self.native.add_event("exception", details)
|
|
254
|
+
except Exception:
|
|
255
|
+
self.native.set_attribute("rippletide.capture.completeness", "capture_error")
|
|
256
|
+
finally:
|
|
257
|
+
self.native.end()
|
|
258
|
+
self.scope.__exit__(exc_type, exc, _traceback)
|
|
259
|
+
self.ended = True
|
|
272
260
|
return False
|
|
273
261
|
|
|
274
262
|
|
|
275
263
|
class Rippletide:
|
|
276
|
-
"""Synchronous
|
|
264
|
+
"""Synchronous policy client with native OpenTelemetry instrumentation."""
|
|
277
265
|
|
|
278
|
-
def __init__(self, *, agent_id: str | None = None, api_key: str | None = None, base_url: str | None = None, name: str | None = None, description: str | None = None,
|
|
266
|
+
def __init__(self, *, agent_id: str | None = None, api_key: str | None = None, base_url: str | None = None, name: str | None = None, description: str | None = None, capture_content: bool = False, runtime_traces: bool = True, disabled: bool | None = None, timeout_ms: int | None = None, warn: Callable[[str], None] | None = None) -> None:
|
|
279
267
|
from_env = _connection_env()
|
|
280
268
|
self.agent_id = agent_id or from_env["agent_id"] or None
|
|
281
269
|
self.api_key = api_key or from_env["api_key"] or None
|
|
282
270
|
self.base_url = _trim_url(base_url or from_env["base_url"] or _base_url_for_key(self.api_key))
|
|
283
|
-
self.
|
|
284
|
-
self.
|
|
271
|
+
self.capture_content = capture_content is True
|
|
272
|
+
self.runtime_traces = runtime_traces is not False
|
|
285
273
|
self.disabled = bool(disabled) if disabled is not None else os.getenv("RIPPLETIDE_DISABLED") == "1" or not bool(self.api_key)
|
|
286
274
|
self.timeout = (timeout_ms or int(os.getenv("RIPPLETIDE_TIMEOUT_MS", "5000"))) / 1000
|
|
287
275
|
self.agent_meta = {key: value for key, value in {"name": name, "description": description}.items() if value}
|
|
288
|
-
self.
|
|
276
|
+
self._telemetry: Telemetry | None = None
|
|
289
277
|
self._registered = False
|
|
290
|
-
self.
|
|
278
|
+
self._warning_handler = warn
|
|
279
|
+
|
|
280
|
+
def _warn(self, message: str) -> None:
|
|
281
|
+
try:
|
|
282
|
+
if self._warning_handler:
|
|
283
|
+
self._warning_handler(message)
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
291
286
|
|
|
292
|
-
def _request(self, method: str, path: str, body: Any | None = None) -> Any:
|
|
287
|
+
def _request(self, method: str, path: str, body: Any | None = None, *, timeout: float | None = None) -> Any:
|
|
293
288
|
if not self.api_key:
|
|
294
289
|
raise RippletideHttpError(401, "UNAUTHENTICATED")
|
|
295
290
|
request = Request(f"{self.base_url}{path}", data=json.dumps(body).encode() if body is not None else None, headers={"x-api-key": self.api_key, **({"content-type": "application/json"} if body is not None else {})}, method=method)
|
|
296
291
|
try:
|
|
297
|
-
with urlopen(request, timeout=self.timeout) as response:
|
|
292
|
+
with urlopen(request, timeout=self.timeout if timeout is None else timeout) as response:
|
|
298
293
|
return json.loads(response.read() or b"{}")
|
|
299
294
|
except HTTPError as error:
|
|
300
295
|
try:
|
|
@@ -319,55 +314,23 @@ class Rippletide:
|
|
|
319
314
|
self._warn("Rippletide registration unavailable; continuing normally")
|
|
320
315
|
return False
|
|
321
316
|
|
|
322
|
-
def _record(self, event_type: str, span: _Span, *, status: str | None = None, input: Any = None, output: Any = None, error: BaseException | None = None, attributes: Mapping[str, Any] | None = None) -> None:
|
|
323
|
-
if self.disabled or not self.agent_id:
|
|
324
|
-
return
|
|
325
|
-
base: dict[str, Any] = {"eventId": str(uuid.uuid4()), "traceId": span.trace_id, "spanId": span.span_id, "kind": span.kind, "name": span.name, "occurredAt": _now(), "captureMode": self.capture_mode, "fidelity": "runtime_trace", "instrumentation": {"name": "rippletide-python", "version": "0.1.0", "language": "python"}}
|
|
326
|
-
if span.parent_span_id:
|
|
327
|
-
base["parentSpanId"] = span.parent_span_id
|
|
328
|
-
for key in ("conversationId", "toolCallId", "invocationId", "decisionId"):
|
|
329
|
-
value = span.options.get(key)
|
|
330
|
-
if isinstance(value, str) and value:
|
|
331
|
-
base[key] = value
|
|
332
|
-
links = span.options.get("links")
|
|
333
|
-
if isinstance(links, list):
|
|
334
|
-
base["links"] = links[:32]
|
|
335
|
-
initial_attributes = _safe(span.options.get("attributes") or {})
|
|
336
|
-
final_attributes = _safe(attributes or {})
|
|
337
|
-
merged_attributes = {**initial_attributes, **final_attributes}
|
|
338
|
-
if merged_attributes:
|
|
339
|
-
base["attributes"] = merged_attributes
|
|
340
|
-
if event_type == "span_start":
|
|
341
|
-
captured = _capture(input, self.capture_mode)
|
|
342
|
-
if captured is not None:
|
|
343
|
-
base["input"] = captured
|
|
344
|
-
base["eventType"] = "span_start"
|
|
345
|
-
else:
|
|
346
|
-
base.update({"eventType": "span_end", "status": status or "ok"})
|
|
347
|
-
captured = _capture(output, self.capture_mode)
|
|
348
|
-
if captured is not None:
|
|
349
|
-
base["output"] = captured
|
|
350
|
-
if error:
|
|
351
|
-
base["error"] = {"name": type(error).__name__, "message": _redact_text(str(error)) if self.capture_mode == "full" else "[REDACTED]"}
|
|
352
|
-
if len(self.events) < 2000:
|
|
353
|
-
self.events.append(base)
|
|
354
317
|
|
|
355
318
|
@contextmanager
|
|
356
|
-
def run(self, *, name: str = "run", input: Any =
|
|
357
|
-
with _Span(self, "run", name,
|
|
319
|
+
def run(self, *, name: str = "run", input: Any = _UNSET, conversation_id: str | None = None, traceparent: str | None = None, tracestate: str | None = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
|
|
320
|
+
with _Span(self, "run", name, traceparent=traceparent, tracestate=tracestate, input=input, conversationId=conversation_id, attributes=attributes) as span:
|
|
358
321
|
yield span
|
|
359
322
|
|
|
360
323
|
@contextmanager
|
|
361
|
-
def agent(self, *, name: str = "agent", input: Any =
|
|
324
|
+
def agent(self, *, name: str = "agent", input: Any = _UNSET, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
|
|
362
325
|
with _Span(self, "agent", name, input=input, attributes=attributes) as span:
|
|
363
326
|
yield span
|
|
364
327
|
|
|
365
328
|
@contextmanager
|
|
366
|
-
def span(self, kind: str, *, name: str, input: Any =
|
|
329
|
+
def span(self, kind: str, *, name: str, input: Any = _UNSET, tool_call_id: str | None = None, invocation_id: str | None = None, decision_id: str | None = None, attributes: Mapping[str, Any] | None = None, links: list[trace.Link] | None = None) -> Iterator[_Span]:
|
|
367
330
|
normalized = {"llm": "model", "other": "operation"}.get(kind, kind)
|
|
368
331
|
if normalized not in {"run", "agent", "model", "tool", "policy", "operation"}:
|
|
369
332
|
normalized = "operation"
|
|
370
|
-
with _Span(self, normalized, name, input=input, toolCallId=tool_call_id, invocationId=invocation_id, decisionId=decision_id, attributes=attributes) as current:
|
|
333
|
+
with _Span(self, normalized, name, input=input, toolCallId=tool_call_id, invocationId=invocation_id, decisionId=decision_id, attributes=attributes, links=links) as current:
|
|
371
334
|
yield current
|
|
372
335
|
|
|
373
336
|
def traced(self, fn: Callable[..., T], *, name: str | None = None, kind: str = "tool") -> Callable[..., T]:
|
|
@@ -379,8 +342,9 @@ class Rippletide:
|
|
|
379
342
|
return wrapped
|
|
380
343
|
|
|
381
344
|
def trace_headers(self) -> dict[str, str]:
|
|
382
|
-
|
|
383
|
-
|
|
345
|
+
headers: dict[str, str] = {}
|
|
346
|
+
TraceContextTextMapPropagator().inject(headers)
|
|
347
|
+
return headers
|
|
384
348
|
|
|
385
349
|
def heartbeat(self, **attributes: Any) -> None:
|
|
386
350
|
with _Span(self, "operation", "heartbeat", root=True, attributes={"signal": "heartbeat", **attributes}):
|
|
@@ -396,7 +360,7 @@ class Rippletide:
|
|
|
396
360
|
fallback = options.get("fallback")
|
|
397
361
|
prompt_type = options.get("type") or ("chat" if isinstance(fallback, list) else "text")
|
|
398
362
|
try:
|
|
399
|
-
self._request("POST", f"/api/apps/{quote(self.agent_id, safe='')}/prompts/{quote(str(name), safe='')}", {"seed": True, "type": prompt_type, "prompt":
|
|
363
|
+
self._request("POST", f"/api/apps/{quote(self.agent_id, safe='')}/prompts/{quote(str(name), safe='')}", {"seed": True, "type": prompt_type, "prompt": capture_value(fallback)[0] if export_content else _withheld_prompt(prompt_type), "config": options.get("config", {}), "source": "sdk", "contentExported": export_content})
|
|
400
364
|
except RippletideHttpError:
|
|
401
365
|
self._warn(f'Rippletide prompt "{name}" unavailable; using local prompt')
|
|
402
366
|
|
|
@@ -546,9 +510,9 @@ class Rippletide:
|
|
|
546
510
|
policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
|
|
547
511
|
if decision and decision.get("disposition") == "BLOCK":
|
|
548
512
|
self._receipt(decision, "PREVENTED", elapsed)
|
|
549
|
-
with self.span("tool", name=tool, input=params, tool_call_id=tool_call_id, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": False, "applicationOutcome": "PREVENTED"}):
|
|
513
|
+
with self.span("tool", name=tool, input=params, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], tool_call_id=tool_call_id, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": False, "applicationOutcome": "PREVENTED"}):
|
|
550
514
|
raise RippletidePolicyBlockedError(action, decision)
|
|
551
|
-
with self.span("tool", name=tool, input=params, tool_call_id=tool_call_id, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": True, "disposition": decision.get("disposition") if decision else "FALLBACK"}) as tool_span:
|
|
515
|
+
with self.span("tool", name=tool, input=params, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], tool_call_id=tool_call_id, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": True, "disposition": decision.get("disposition") if decision else "FALLBACK"}) as tool_span:
|
|
552
516
|
self._receipt(decision, "EXECUTED", elapsed)
|
|
553
517
|
result = execute(params)
|
|
554
518
|
tool_span.set_output(result)
|
|
@@ -573,6 +537,7 @@ class Rippletide:
|
|
|
573
537
|
evaluated_context = _clone_policy_value(dict(context or {}), "trusted context")
|
|
574
538
|
decision: Mapping[str, Any] | None = None
|
|
575
539
|
elapsed = 0
|
|
540
|
+
policy_span = None
|
|
576
541
|
|
|
577
542
|
if allow_result_export and not self.disabled:
|
|
578
543
|
invocation_id = str(uuid.uuid4())
|
|
@@ -580,6 +545,7 @@ class Rippletide:
|
|
|
580
545
|
started = time.monotonic()
|
|
581
546
|
with self.span("policy", name=f"after_action:{action}", input=body, tool_call_id=tool_call_id, invocation_id=invocation_id, attributes={"trigger": "after_action", "action": action, "rawResultExport": True}) as policy_span:
|
|
582
547
|
decision = self._decide_result(body, action, evaluated_result, evaluated_items)
|
|
548
|
+
policy_span.options["decisionId"] = decision.get("decisionId") if decision else None
|
|
583
549
|
elapsed = int((time.monotonic() - started) * 1000)
|
|
584
550
|
policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
|
|
585
551
|
|
|
@@ -601,7 +567,7 @@ class Rippletide:
|
|
|
601
567
|
})
|
|
602
568
|
outcome = "RESULT_FILTERED" if excluded_items else "RESULT_DELIVERED"
|
|
603
569
|
filtered = {"result": evaluated_result, "deliveredItems": delivered_items, "excludedItems": excluded_items, "policyDecision": _clone_policy_value(decision, "policy decision") if decision else None}
|
|
604
|
-
with self.span("operation", name="result_application", input=filtered, tool_call_id=tool_call_id, invocation_id=decision.get("invocationId") if decision else None, decision_id=decision.get("decisionId") if decision else None, attributes={"action": action, "applicationOutcome": outcome, "deliveredItemCount": len(delivered_items), "excludedItemCount": len(excluded_items), "executed": True}) as result_span:
|
|
570
|
+
with self.span("operation", name="result_application", input=filtered, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})] if policy_span else [], tool_call_id=tool_call_id, invocation_id=decision.get("invocationId") if decision else None, decision_id=decision.get("decisionId") if decision else None, attributes={"action": action, "applicationOutcome": outcome, "deliveredItemCount": len(delivered_items), "excludedItemCount": len(excluded_items), "executed": True}) as result_span:
|
|
605
571
|
try:
|
|
606
572
|
applied = apply(filtered)
|
|
607
573
|
result_span.set_output(applied)
|
|
@@ -631,24 +597,21 @@ class Rippletide:
|
|
|
631
597
|
policy_span.set_output(decision, disposition=decision.get("disposition") if decision else "FALLBACK")
|
|
632
598
|
if decision and decision.get("disposition") == "BLOCK":
|
|
633
599
|
self._receipt(decision, "SUPPRESSED", elapsed)
|
|
634
|
-
with self.span("operation", name="response_delivery", input=response, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": False, "applicationOutcome": "SUPPRESSED"}):
|
|
600
|
+
with self.span("operation", name="response_delivery", input=response, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": False, "applicationOutcome": "SUPPRESSED"}):
|
|
635
601
|
raise RippletidePolicyBlockedError(action, decision)
|
|
636
|
-
with self.span("operation", name="response_delivery", input=response, invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": True, "applicationOutcome": "DELIVERED", "disposition": decision.get("disposition") if decision else "FALLBACK"}) as delivery_span:
|
|
602
|
+
with self.span("operation", name="response_delivery", input=response, links=[trace.Link(policy_span.native.get_span_context(), {"rippletide.link.type": "depends_on"})], invocation_id=invocation_id, decision_id=decision_id, attributes={"action": action, "executed": True, "applicationOutcome": "DELIVERED", "disposition": decision.get("disposition") if decision else "FALLBACK"}) as delivery_span:
|
|
637
603
|
self._receipt(decision, "DELIVERED", elapsed)
|
|
638
604
|
result = deliver(response)
|
|
639
605
|
delivery_span.set_output(result)
|
|
640
606
|
return result
|
|
641
607
|
|
|
642
608
|
def flush(self) -> bool:
|
|
643
|
-
if self.disabled or not self.agent_id or not self.
|
|
609
|
+
if self.disabled or not self.agent_id or not self.runtime_traces or self._telemetry is None:
|
|
644
610
|
return False
|
|
645
611
|
self._ensure_registered()
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
all_sent = False
|
|
653
|
-
self._warn("Rippletide runtime export unavailable; dropped trace batch")
|
|
654
|
-
return all_sent
|
|
612
|
+
return self._telemetry.flush()
|
|
613
|
+
|
|
614
|
+
def shutdown(self) -> None:
|
|
615
|
+
self.flush()
|
|
616
|
+
if self._telemetry:
|
|
617
|
+
self._telemetry.shutdown()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from threading import Lock
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from google.protobuf.json_format import MessageToDict
|
|
16
|
+
from opentelemetry import context, trace
|
|
17
|
+
from opentelemetry._logs import LogRecord
|
|
18
|
+
from opentelemetry.exporter.otlp.proto.common._log_encoder import encode_logs
|
|
19
|
+
from opentelemetry.exporter.otlp.proto.common.trace_encoder import encode_spans
|
|
20
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
21
|
+
from opentelemetry.sdk._logs.export import LogRecordExporter, LogRecordExportResult, SimpleLogRecordProcessor
|
|
22
|
+
from opentelemetry.sdk.resources import Resource
|
|
23
|
+
from opentelemetry.sdk.trace import SpanLimits, TracerProvider
|
|
24
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
|
|
25
|
+
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
|
|
26
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
27
|
+
|
|
28
|
+
SCHEMA_URL = "https://opentelemetry.io/schemas/1.37.0"
|
|
29
|
+
SENSITIVE = re.compile(r"^(api[_-]?key|access[_-]?token|auth[_-]?token|authorization|client[_-]?secret|cookie|password|private[_-]?key|proxy-authorization|refresh[_-]?token|secret|session[_-]?token|set-cookie|x-api-key)$", re.I)
|
|
30
|
+
INLINE_AUTH = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9+/_=.,:;~-]{8,}", re.I)
|
|
31
|
+
INLINE_SECRET = re.compile(r'''(["']?(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|password|private[_-]?key|refresh[_-]?token|secret|session[_-]?token)["']?\s*[:=]\s*)(["']?)[^\s,;}"']{4,}(["']?)''', re.I)
|
|
32
|
+
CORRELATIONS = {"conversationId": "gen_ai.conversation.id", "toolCallId": "gen_ai.tool.call.id", "invocationId": "rippletide.invocation.id", "decisionId": "rippletide.policy.decision.id"}
|
|
33
|
+
INDEXED = {
|
|
34
|
+
"provider": "gen_ai.provider.name", "model": "gen_ai.request.model", "responseId": "gen_ai.response.id",
|
|
35
|
+
"inputTokens": "gen_ai.usage.input_tokens", "outputTokens": "gen_ai.usage.output_tokens", "totalTokens": "rippletide.model.total_tokens",
|
|
36
|
+
"streaming": "rippletide.model.streaming", "toolCallIds": "rippletide.model.tool_call_ids", "toolCallCount": "rippletide.model.tool_call_count",
|
|
37
|
+
"captureCompleteness": "rippletide.capture.completeness", "trigger": "rippletide.policy.trigger", "action": "rippletide.policy.action",
|
|
38
|
+
"disposition": "rippletide.policy.disposition", "decisionRoundtripMs": "rippletide.policy.roundtrip_ms", "failOpen": "rippletide.policy.fail_open",
|
|
39
|
+
"policyFallback": "rippletide.policy.fallback", "executed": "rippletide.action.executed", "applicationOutcome": "rippletide.action.application_outcome",
|
|
40
|
+
"deliveredItemCount": "rippletide.result.delivered_item_count", "excludedItemCount": "rippletide.result.excluded_item_count",
|
|
41
|
+
"signal": "rippletide.signal", "source": "rippletide.source", "standalone": "rippletide.standalone",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sanitized_text(value: str) -> str:
|
|
46
|
+
value = INLINE_SECRET.sub(r"\1\2[REDACTED]\3", INLINE_AUTH.sub(r"\1 [REDACTED]", value))
|
|
47
|
+
return value.replace("\0", "\ufffd").encode("utf-8", errors="replace").decode("utf-8")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def capture_value(value: Any) -> tuple[Any, bool]:
|
|
51
|
+
remaining, truncated, ancestors = 200, False, set()
|
|
52
|
+
|
|
53
|
+
def visit(item: Any, depth: int = 0, key: str = "") -> Any:
|
|
54
|
+
nonlocal remaining, truncated
|
|
55
|
+
if depth > 6 or remaining <= 0:
|
|
56
|
+
truncated = True
|
|
57
|
+
return "…"
|
|
58
|
+
remaining -= 1
|
|
59
|
+
if SENSITIVE.match(key):
|
|
60
|
+
return "[REDACTED]"
|
|
61
|
+
if item is None or isinstance(item, (bool, int)):
|
|
62
|
+
return item
|
|
63
|
+
if isinstance(item, float):
|
|
64
|
+
return item if math.isfinite(item) else str(item)
|
|
65
|
+
if isinstance(item, str):
|
|
66
|
+
clean = sanitized_text(item)
|
|
67
|
+
truncated |= len(clean) > 4000
|
|
68
|
+
return clean[:4000]
|
|
69
|
+
if isinstance(item, (Mapping, list, tuple)):
|
|
70
|
+
if id(item) in ancestors:
|
|
71
|
+
truncated = True
|
|
72
|
+
return "[Circular]"
|
|
73
|
+
ancestors.add(id(item))
|
|
74
|
+
truncated |= len(item) > 50
|
|
75
|
+
if isinstance(item, Mapping):
|
|
76
|
+
result = {sanitized_text(str(child_key))[:128]: visit(child, depth + 1, str(child_key)) for child_key, child in list(item.items())[:50]}
|
|
77
|
+
else:
|
|
78
|
+
result = [visit(child, depth + 1) for child in item[:50]]
|
|
79
|
+
ancestors.remove(id(item))
|
|
80
|
+
return result
|
|
81
|
+
return visit(str(item), depth)
|
|
82
|
+
|
|
83
|
+
captured = visit(value)
|
|
84
|
+
encoded = json.dumps(captured, ensure_ascii=False, separators=(",", ":"))
|
|
85
|
+
if len(encoded) > 8000:
|
|
86
|
+
return {"truncated": True, "preview": encoded[:3980]}, True
|
|
87
|
+
return captured, truncated
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def otlp_json(message: Any) -> dict:
|
|
91
|
+
value = MessageToDict(message, use_integers_for_enums=True)
|
|
92
|
+
|
|
93
|
+
def normalize(item: Any) -> Any:
|
|
94
|
+
if isinstance(item, list):
|
|
95
|
+
return [normalize(child) for child in item]
|
|
96
|
+
if isinstance(item, dict):
|
|
97
|
+
return {key: base64.b64decode(child).hex() if key in {"traceId", "spanId", "parentSpanId"} and isinstance(child, str) else normalize(child) for key, child in item.items()}
|
|
98
|
+
return item
|
|
99
|
+
|
|
100
|
+
return normalize(value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _SpanExporter(SpanExporter):
|
|
104
|
+
def __init__(self, telemetry: "Telemetry") -> None:
|
|
105
|
+
self.telemetry = telemetry
|
|
106
|
+
|
|
107
|
+
def export(self, spans: Any) -> SpanExportResult:
|
|
108
|
+
self.telemetry.enqueue("traces", otlp_json(encode_spans(spans)))
|
|
109
|
+
return SpanExportResult.SUCCESS
|
|
110
|
+
|
|
111
|
+
def shutdown(self) -> None:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _LogExporter(LogRecordExporter):
|
|
116
|
+
def __init__(self, telemetry: "Telemetry") -> None:
|
|
117
|
+
self.telemetry = telemetry
|
|
118
|
+
|
|
119
|
+
def export(self, records: Any) -> LogRecordExportResult:
|
|
120
|
+
self.telemetry.enqueue("logs", otlp_json(encode_logs(records)))
|
|
121
|
+
return LogRecordExportResult.SUCCESS
|
|
122
|
+
|
|
123
|
+
def shutdown(self) -> None:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Telemetry:
|
|
128
|
+
def __init__(self, client: Any) -> None:
|
|
129
|
+
self.client = client
|
|
130
|
+
self.pending = {"traces": deque(maxlen=2000), "logs": deque(maxlen=2000)}
|
|
131
|
+
self.lock = Lock()
|
|
132
|
+
resource = Resource.create({"service.name": "rippletide-instrumented-agent"})
|
|
133
|
+
self.provider = TracerProvider(resource=resource, sampler=ALWAYS_ON, span_limits=SpanLimits(max_attributes=128, max_events=128, max_links=32), shutdown_on_exit=False)
|
|
134
|
+
self.provider.add_span_processor(SimpleSpanProcessor(_SpanExporter(self)))
|
|
135
|
+
try:
|
|
136
|
+
sdk_version = version("rippletide-python")
|
|
137
|
+
except PackageNotFoundError:
|
|
138
|
+
sdk_version = None
|
|
139
|
+
self.tracer = self.provider.get_tracer("rippletide-python", sdk_version, SCHEMA_URL)
|
|
140
|
+
self.logger_provider = LoggerProvider(resource=resource, shutdown_on_exit=False)
|
|
141
|
+
self.logger_provider.add_log_record_processor(SimpleLogRecordProcessor(_LogExporter(self)))
|
|
142
|
+
self.logger = self.logger_provider.get_logger("rippletide-python", sdk_version, SCHEMA_URL)
|
|
143
|
+
|
|
144
|
+
def enqueue(self, signal: str, packet: dict) -> None:
|
|
145
|
+
with self.lock:
|
|
146
|
+
self.pending[signal].append(packet)
|
|
147
|
+
|
|
148
|
+
def attributes(self, kind: str, options: dict, extra: dict | None = None) -> dict:
|
|
149
|
+
raw = {**(options.get("attributes") or {}), **(extra or {})}
|
|
150
|
+
captured, truncated = capture_value(raw if self.client.capture_content else {key: value for key, value in raw.items() if key in INDEXED or key == "finishReason"})
|
|
151
|
+
result = {"rippletide.schema.version": "1", "rippletide.agent.id": self.client.agent_id, "rippletide.span.kind": kind, "rippletide.capture.mode": "full", "rippletide.evidence.fidelity": "runtime_trace"}
|
|
152
|
+
loss = []
|
|
153
|
+
for source, target in {**INDEXED, **CORRELATIONS}.items():
|
|
154
|
+
value = options.get(source) if source in CORRELATIONS else raw.get(source)
|
|
155
|
+
if isinstance(value, str):
|
|
156
|
+
clean = sanitized_text(value)
|
|
157
|
+
result[target] = clean[:512]
|
|
158
|
+
if len(clean) > 512:
|
|
159
|
+
loss.append(target)
|
|
160
|
+
elif isinstance(value, (bool, int, float)) and (not isinstance(value, (int, float)) or math.isfinite(value)):
|
|
161
|
+
result[target] = value
|
|
162
|
+
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
163
|
+
result[target] = [sanitized_text(item)[:512] for item in value[:50]]
|
|
164
|
+
if len(value) > 50 or any(len(item) > 512 for item in value[:50]):
|
|
165
|
+
loss.append(target)
|
|
166
|
+
if isinstance(raw.get("finishReason"), str):
|
|
167
|
+
result["gen_ai.response.finish_reasons"] = [sanitized_text(raw["finishReason"])[:512]]
|
|
168
|
+
if self.client.capture_content:
|
|
169
|
+
result["rippletide.attributes.json"] = json.dumps(captured, ensure_ascii=False, separators=(",", ":"))
|
|
170
|
+
if truncated:
|
|
171
|
+
loss.append("rippletide.attributes.json")
|
|
172
|
+
for field in ("input", "output"):
|
|
173
|
+
if field in options:
|
|
174
|
+
value, cut = capture_value(options[field])
|
|
175
|
+
result[f"rippletide.{field}.json"] = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
176
|
+
if cut:
|
|
177
|
+
loss.append(f"rippletide.{field}.json")
|
|
178
|
+
completeness = raw.get("captureCompleteness")
|
|
179
|
+
result["rippletide.capture.completeness"] = completeness if isinstance(completeness, str) and completeness in {"capture_error", "stream_not_consumed"} else "complete" if self.client.capture_content else "not_captured"
|
|
180
|
+
if loss:
|
|
181
|
+
result["rippletide.capture.completeness"] = "truncated"
|
|
182
|
+
if loss:
|
|
183
|
+
result["rippletide.capture.truncated_fields"] = loss
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
def start(self, kind: str, name: str, options: dict, root: bool, traceparent: str | None) -> Any:
|
|
187
|
+
parent = TraceContextTextMapPropagator().extract({"traceparent": traceparent, "tracestate": options.get("tracestate") or ""}) if traceparent else context.Context() if root else context.get_current()
|
|
188
|
+
attributes = self.attributes(kind, options)
|
|
189
|
+
if kind == "tool":
|
|
190
|
+
attributes["gen_ai.tool.name"] = sanitized_text(name)[:512]
|
|
191
|
+
if kind == "agent":
|
|
192
|
+
attributes["gen_ai.agent.name"] = sanitized_text(name)[:512]
|
|
193
|
+
links = [trace.Link(link.context, {"rippletide.link.type": link.attributes["rippletide.link.type"]})
|
|
194
|
+
for link in (options.get("links") or [])[:32]
|
|
195
|
+
if isinstance(link, trace.Link) and link.context.is_valid and (link.attributes or {}).get("rippletide.link.type") in {"depends_on", "follows_from"}]
|
|
196
|
+
span = self.tracer.start_span(sanitized_text(name)[:512], context=parent, kind=trace.SpanKind.CLIENT if kind in {"policy", "model"} else trace.SpanKind.INTERNAL, attributes=attributes, links=links)
|
|
197
|
+
span.add_event("rippletide.span.started", attributes, span.start_time)
|
|
198
|
+
span_context = span.get_span_context()
|
|
199
|
+
start_attributes = {**attributes, "rippletide.event.id": str(uuid.uuid4())}
|
|
200
|
+
if links:
|
|
201
|
+
start_attributes["rippletide.start.links.json"] = json.dumps([{
|
|
202
|
+
"traceId": format(link.context.trace_id, "032x"), "spanId": format(link.context.span_id, "016x"),
|
|
203
|
+
"flags": int(link.context.trace_flags), "traceState": link.context.trace_state.to_header(),
|
|
204
|
+
"attributes": [{"key": key, "value": {"stringValue": str(value)}} for key, value in (link.attributes or {}).items()],
|
|
205
|
+
} for link in links], separators=(",", ":"))
|
|
206
|
+
parent_span = trace.get_current_span(parent).get_span_context()
|
|
207
|
+
if parent_span.is_valid:
|
|
208
|
+
start_attributes["rippletide.parent.span_id"] = format(parent_span.span_id, "016x")
|
|
209
|
+
self.logger.emit(LogRecord(timestamp=span.start_time, trace_id=span_context.trace_id, span_id=span_context.span_id, trace_flags=span_context.trace_flags, event_name="rippletide.span.started", body=span.name, attributes=start_attributes))
|
|
210
|
+
return span
|
|
211
|
+
|
|
212
|
+
def flush(self) -> bool:
|
|
213
|
+
success = True
|
|
214
|
+
deadline = time.monotonic() + self.client.timeout
|
|
215
|
+
for signal, queue in self.pending.items():
|
|
216
|
+
while queue and time.monotonic() < deadline:
|
|
217
|
+
with self.lock:
|
|
218
|
+
batch = [queue.popleft() for _ in range(min(4, len(queue)))]
|
|
219
|
+
field = "resourceSpans" if signal == "traces" else "resourceLogs"
|
|
220
|
+
packet = {field: [resource for item in batch for resource in item.get(field, [])]}
|
|
221
|
+
for attempt in range(3):
|
|
222
|
+
remaining = deadline - time.monotonic()
|
|
223
|
+
if remaining <= 0:
|
|
224
|
+
success = False
|
|
225
|
+
break
|
|
226
|
+
try:
|
|
227
|
+
response = self.client._request("POST", f"/v1/{signal}", packet, timeout=remaining)
|
|
228
|
+
rejected = int(response.get("partialSuccess", {}).get("rejectedSpans" if signal == "traces" else "rejectedLogRecords", 0))
|
|
229
|
+
success &= rejected == 0
|
|
230
|
+
break
|
|
231
|
+
except Exception as error:
|
|
232
|
+
if getattr(error, "status", None) not in {429, 502, 503, 504} or attempt == 2:
|
|
233
|
+
success = False
|
|
234
|
+
self.client._warn("Rippletide runtime export unavailable; dropped batch")
|
|
235
|
+
break
|
|
236
|
+
time.sleep(min(0.05 * 2 ** attempt, max(0, deadline - time.monotonic())))
|
|
237
|
+
return success and not any(self.pending.values())
|
|
238
|
+
|
|
239
|
+
def shutdown(self) -> None:
|
|
240
|
+
self.provider.shutdown()
|
|
241
|
+
self.logger_provider.shutdown()
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rippletide-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Monitor-first Python SDK for Rippletide-connected agents
|
|
5
5
|
License: UNLICENSED
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: opentelemetry-sdk==1.40.0
|
|
9
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-common==1.40.0
|
|
8
10
|
|
|
9
11
|
# Rippletide Python
|
|
10
12
|
|
|
@@ -12,6 +14,41 @@ Description-Content-Type: text/markdown
|
|
|
12
14
|
taking ownership of its model traffic. It has no provider dependencies: wrap a
|
|
13
15
|
hand-written provider or internal HTTP call at the existing model seam.
|
|
14
16
|
|
|
17
|
+
## Install and connect
|
|
18
|
+
|
|
19
|
+
The native OTel release candidate is `rippletide-python==0.2.0`. It requires
|
|
20
|
+
the platform's OTLP ingestion/readers and does not provide V1 trace compatibility.
|
|
21
|
+
Until it is published, validate it by installing a locally built wheel; do not
|
|
22
|
+
assume the unversioned registry install below contains candidate changes.
|
|
23
|
+
|
|
24
|
+
Requires Python 3.10 or later. From the existing agent repository, use its Python
|
|
25
|
+
environment, or create one if it has none:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python3 -m venv .venv
|
|
29
|
+
source .venv/bin/activate
|
|
30
|
+
python -m pip install rippletide-python
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
On Windows, activate with `.venv\Scripts\Activate.ps1` in PowerShell instead.
|
|
34
|
+
Keep the dependency in the project's requirements or package-manager manifest.
|
|
35
|
+
|
|
36
|
+
In Rippletide, choose **Connect an agent → Agent (Python)**. Save the generated
|
|
37
|
+
credentials in the repository's git-ignored `.env`, then apply the generated
|
|
38
|
+
prompt with your coding agent in that repository. The CLI also supports
|
|
39
|
+
`rippletide connect --kind agent --language python --print-prompt`.
|
|
40
|
+
|
|
41
|
+
Run a real turn that uses a tool and delivers a response. Confirm **Runtime
|
|
42
|
+
verified (5/5)** in Fleet and the run's tool calls and **Delivered** outcome in
|
|
43
|
+
Activity. Copying a prompt or storing credentials alone does not prove a
|
|
44
|
+
connection. The SDK supports synchronous call sites. Async execution and
|
|
45
|
+
streaming are not supported.
|
|
46
|
+
|
|
47
|
+
## Instrument existing call sites
|
|
48
|
+
|
|
49
|
+
The following fragment belongs inside the existing request handler; reuse the
|
|
50
|
+
agent's own provider, messages, tools, context, and response sink.
|
|
51
|
+
|
|
15
52
|
```python
|
|
16
53
|
from rippletide import Rippletide, load_rippletide_env
|
|
17
54
|
|
|
@@ -48,18 +85,36 @@ connection takes precedence over those legacy values; explicit constructor
|
|
|
48
85
|
arguments take precedence over either format. Never use a Platform key in the
|
|
49
86
|
agent or commit the `.env`.
|
|
50
87
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
88
|
+
## Policy failures and capture
|
|
89
|
+
|
|
90
|
+
The client starts disabled when no Connection key is present. For
|
|
91
|
+
`guard_tool_call()` and `deliver_response()`:
|
|
92
|
+
|
|
93
|
+
- Network failures, 5xx responses, rate limits, and malformed or interrupted HTTP
|
|
94
|
+
responses fail open: the protected callback can still run.
|
|
95
|
+
- An enforce-mode `BLOCK` raises `RippletidePolicyBlockedError` before the
|
|
96
|
+
protected callback runs. An observe-only `WOULD_BLOCK` permits execution.
|
|
97
|
+
- HTTP `400`, `401`, `403`, or `404` from the policy decision endpoint raises
|
|
98
|
+
`RippletidePolicyDecisionError` before the callback runs. These request,
|
|
99
|
+
authentication, authorization, or routing failures are not swallowed as
|
|
100
|
+
telemetry outages.
|
|
101
|
+
|
|
102
|
+
Result filtering has its own fallback rules, described below. Application input
|
|
103
|
+
validation and exceptions from the agent's own callbacks can also propagate.
|
|
104
|
+
|
|
105
|
+
Runtime instrumentation uses native OpenTelemetry spans and start logs, exported as
|
|
106
|
+
OTLP/HTTP JSON to `/v1/traces` and `/v1/logs`. Providers are local to the client;
|
|
107
|
+
the SDK does not replace application providers. The only profile is `full`, but
|
|
108
|
+
content is omitted unless `Rippletide(capture_content=True)` is explicit. Captured
|
|
109
|
+
content is bounded and credential-redacted, including nested Python mappings
|
|
110
|
+
such as `UserDict` and `MappingProxyType`, not generally PII-filtered. The old
|
|
111
|
+
capture modes are unsupported. `runtime_traces=False` disables evidence export
|
|
112
|
+
without disabling policy checks. Always call `flush()` before a short-lived
|
|
113
|
+
worker exits, and `shutdown()` when the client is no longer needed.
|
|
59
114
|
|
|
60
115
|
Tool and response policy spans include the invocation ID; once a decision arrives, their end
|
|
61
116
|
events and the protected action spans include its decision ID, including in
|
|
62
|
-
|
|
117
|
+
the default content-withheld posture. These IDs correlate the action with its policy receipt.
|
|
63
118
|
|
|
64
119
|
Prompt inventory is recorded with its content withheld by default. Pass
|
|
65
120
|
`allow_content_export=True` to `register_prompts(...)` only when the prompt
|
|
@@ -70,7 +125,10 @@ owner has explicitly approved storing the prompt text in Rippletide.
|
|
|
70
125
|
For an eligible collection returned by a tool, declare its result contract and
|
|
71
126
|
pass `allow_result_export=True`. This evaluates the complete result transiently
|
|
72
127
|
at Rippletide; do this only with the owner's explicit approval. It is false by
|
|
73
|
-
default
|
|
128
|
+
default. Transient policy failures and malformed or mismatched plans forward
|
|
129
|
+
every item unchanged. A missing result endpoint (`404` with no error code or
|
|
130
|
+
`NOT_FOUND`) also uses this fallback. Other deterministic request or permission
|
|
131
|
+
rejections raise `RippletidePolicyDecisionError` before `apply` runs.
|
|
74
132
|
|
|
75
133
|
```python
|
|
76
134
|
rippletide.tools("catalog", TOOLS, results={
|
|
@@ -2,8 +2,10 @@ README.md
|
|
|
2
2
|
pyproject.toml
|
|
3
3
|
rippletide/__init__.py
|
|
4
4
|
rippletide/client.py
|
|
5
|
+
rippletide/telemetry.py
|
|
5
6
|
rippletide_python.egg-info/PKG-INFO
|
|
6
7
|
rippletide_python.egg-info/SOURCES.txt
|
|
7
8
|
rippletide_python.egg-info/dependency_links.txt
|
|
9
|
+
rippletide_python.egg-info/requires.txt
|
|
8
10
|
rippletide_python.egg-info/top_level.txt
|
|
9
11
|
tests/test_client.py
|
|
@@ -3,7 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
5
|
import unittest
|
|
6
|
+
from collections import UserDict
|
|
7
|
+
from types import MappingProxyType
|
|
6
8
|
from hashlib import sha256
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
10
|
from importlib.util import module_from_spec, spec_from_file_location
|
|
8
11
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
12
|
from http.client import IncompleteRead
|
|
@@ -16,6 +19,11 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
|
|
|
16
19
|
|
|
17
20
|
from rippletide import Rippletide, RippletidePolicyBlockedError, RippletidePolicyDecisionError
|
|
18
21
|
|
|
22
|
+
from otel_helpers import runtime_facts
|
|
23
|
+
from otel_helpers import attributes as otel_attributes
|
|
24
|
+
from opentelemetry import trace
|
|
25
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
26
|
+
|
|
19
27
|
AGENT_ID = "00000000-0000-4000-8000-000000000042"
|
|
20
28
|
|
|
21
29
|
|
|
@@ -105,6 +113,24 @@ class PlatformHandler(BaseHTTPRequestHandler):
|
|
|
105
113
|
|
|
106
114
|
|
|
107
115
|
class ClientTests(unittest.TestCase):
|
|
116
|
+
def test_mapping_credentials_are_redacted_in_spans_and_start_logs(self):
|
|
117
|
+
self.client.capture_content = True
|
|
118
|
+
value = MappingProxyType({"nested": UserDict({"Cookie": "session=fixture-private-cookie", "Authorization": "Token fixture-private-token", "safe": "visible"})})
|
|
119
|
+
with self.client.run(name="mapping", input=value):
|
|
120
|
+
pass
|
|
121
|
+
self.assertTrue(self.client.flush())
|
|
122
|
+
signals = set()
|
|
123
|
+
for path, packet in PlatformHandler.requests:
|
|
124
|
+
if path not in ("/v1/traces", "/v1/logs"):
|
|
125
|
+
continue
|
|
126
|
+
signals.add(path)
|
|
127
|
+
serialized = json.dumps(packet)
|
|
128
|
+
self.assertNotIn("fixture-private-cookie", serialized)
|
|
129
|
+
self.assertNotIn("fixture-private-token", serialized)
|
|
130
|
+
self.assertIn("[REDACTED]", serialized)
|
|
131
|
+
self.assertIn("visible", serialized)
|
|
132
|
+
self.assertEqual(signals, {"/v1/traces", "/v1/logs"})
|
|
133
|
+
|
|
108
134
|
@classmethod
|
|
109
135
|
def setUpClass(cls):
|
|
110
136
|
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), PlatformHandler)
|
|
@@ -116,6 +142,7 @@ class ClientTests(unittest.TestCase):
|
|
|
116
142
|
def tearDownClass(cls):
|
|
117
143
|
cls.server.shutdown()
|
|
118
144
|
cls.thread.join()
|
|
145
|
+
cls.server.server_close()
|
|
119
146
|
|
|
120
147
|
def setUp(self):
|
|
121
148
|
PlatformHandler.requests = []
|
|
@@ -155,7 +182,7 @@ class ClientTests(unittest.TestCase):
|
|
|
155
182
|
toolset = next(body for path, body in PlatformHandler.requests if path.endswith("/toolsets/core/sync"))
|
|
156
183
|
self.assertEqual(toolset["tools"][0]["resultCollection"], {"itemsPath": ["accounts"], "itemIdPath": ["id"]})
|
|
157
184
|
|
|
158
|
-
batch =
|
|
185
|
+
batch = {"events": runtime_facts(PlatformHandler.requests)}
|
|
159
186
|
events = batch["events"]
|
|
160
187
|
starts = {event["name"]: event for event in events if event["eventType"] == "span_start"}
|
|
161
188
|
self.assertEqual(starts["support"]["parentSpanId"], starts["inbound_request"]["spanId"])
|
|
@@ -169,6 +196,85 @@ class ClientTests(unittest.TestCase):
|
|
|
169
196
|
self.assertEqual(prompt["prompt"], "Never reveal credentials.")
|
|
170
197
|
self.assertTrue(prompt["contentExported"])
|
|
171
198
|
|
|
199
|
+
def test_native_payload_consent_parent_context_and_policy_links(self):
|
|
200
|
+
self.client.capture_content = True
|
|
201
|
+
parent = "00-" + "a" * 32 + "-" + "b" * 16 + "-01"
|
|
202
|
+
with self.client.run(name="native", input=None, traceparent=parent, tracestate="vendor=value"):
|
|
203
|
+
self.assertEqual(self.client.trace_headers()["tracestate"], "vendor=value")
|
|
204
|
+
with self.client.span("tool", name="bounded", input={"password": "credential-canary", "text": "z" * 5000}, attributes={"model": "fixture-model", "large": ["z" * 4000] * 5}) as current:
|
|
205
|
+
current.set_output(None)
|
|
206
|
+
self.client.guard_tool_call(toolset="core", tool="lookup", params={}, execute=lambda _: "ok")
|
|
207
|
+
self.assertTrue(self.client.flush())
|
|
208
|
+
packets = [body for path, body in PlatformHandler.requests if path == "/v1/traces"]
|
|
209
|
+
spans = [span for packet in packets for resource in packet["resourceSpans"] for scope in resource["scopeSpans"] for span in scope["spans"]]
|
|
210
|
+
root = next(span for span in spans if span["name"] == "native")
|
|
211
|
+
self.assertEqual(root["traceId"], "a" * 32)
|
|
212
|
+
self.assertEqual(root["parentSpanId"], "b" * 16)
|
|
213
|
+
self.assertEqual(otel_attributes(root["attributes"])["rippletide.input.json"], "null")
|
|
214
|
+
bounded = next(span for span in spans if span["name"] == "bounded")
|
|
215
|
+
captured = otel_attributes(bounded["attributes"])
|
|
216
|
+
self.assertEqual(captured["rippletide.capture.completeness"], "truncated")
|
|
217
|
+
self.assertEqual(captured["rippletide.output.json"], "null")
|
|
218
|
+
self.assertEqual(captured["gen_ai.request.model"], "fixture-model")
|
|
219
|
+
self.assertNotIn("credential-canary", json.dumps(packets))
|
|
220
|
+
action = next(span for span in spans if span["name"] == "lookup")
|
|
221
|
+
policy = next(span for span in spans if span["name"] == "before_action:core/lookup")
|
|
222
|
+
self.assertEqual(action["links"][0]["spanId"], policy["spanId"])
|
|
223
|
+
self.assertEqual(otel_attributes(action["links"][0]["attributes"])["rippletide.link.type"], "depends_on")
|
|
224
|
+
self.assertTrue(any(path == "/v1/logs" for path, _ in PlatformHandler.requests))
|
|
225
|
+
for path, packet in PlatformHandler.requests:
|
|
226
|
+
for resource_key, scope_key in [("resourceSpans", "scopeSpans"), ("resourceLogs", "scopeLogs")]:
|
|
227
|
+
for resource in packet.get(resource_key, []):
|
|
228
|
+
self.assertTrue(all(scope["scope"]["version"] == version("rippletide-python") for scope in resource[scope_key]))
|
|
229
|
+
self.assertFalse(any(path.startswith("/v1/runtime/traces") for path, _ in PlatformHandler.requests))
|
|
230
|
+
|
|
231
|
+
def test_trace_and_log_scopes_follow_distribution_metadata_without_requiring_it(self):
|
|
232
|
+
for expected in ["9.8.7", None]:
|
|
233
|
+
metadata = {"return_value": expected} if expected else {"side_effect": PackageNotFoundError("rippletide-python")}
|
|
234
|
+
with self.subTest(version=expected), patch("rippletide.telemetry.version", **metadata):
|
|
235
|
+
client = Rippletide(agent_id=AGENT_ID, api_key="test-connection-key", base_url=self.base_url)
|
|
236
|
+
try:
|
|
237
|
+
PlatformHandler.requests = []
|
|
238
|
+
self.assertEqual(client.traced(lambda: "ok", name="versioned-tool")(), "ok")
|
|
239
|
+
self.assertTrue(client.flush())
|
|
240
|
+
scopes = []
|
|
241
|
+
for path, packet in PlatformHandler.requests:
|
|
242
|
+
for resource_key, scope_key in [("resourceSpans", "scopeSpans"), ("resourceLogs", "scopeLogs")]:
|
|
243
|
+
scopes.extend(scope["scope"] for resource in packet.get(resource_key, []) for scope in resource[scope_key])
|
|
244
|
+
self.assertGreaterEqual(len(scopes), 2)
|
|
245
|
+
self.assertTrue(all(scope["name"] == "rippletide-python" and (scope.get("version") or None) == expected for scope in scopes))
|
|
246
|
+
finally:
|
|
247
|
+
client.shutdown()
|
|
248
|
+
|
|
249
|
+
def test_disabled_telemetry_preserves_policy_and_host_context(self):
|
|
250
|
+
self.client.runtime_traces = False
|
|
251
|
+
provider = TracerProvider(shutdown_on_exit=False)
|
|
252
|
+
with provider.get_tracer("host").start_as_current_span("host") as parent:
|
|
253
|
+
with self.client.run():
|
|
254
|
+
self.assertEqual(trace.get_current_span().get_span_context(), parent.get_span_context())
|
|
255
|
+
self.assertEqual(self.client.guard_tool_call(toolset="core", tool="lookup", params={}, execute=lambda _: "ok"), "ok")
|
|
256
|
+
self.assertTrue(parent.is_recording())
|
|
257
|
+
provider.shutdown()
|
|
258
|
+
self.client.flush()
|
|
259
|
+
self.assertTrue(any(path == "/v1/policy/decide" for path, _ in PlatformHandler.requests))
|
|
260
|
+
self.assertFalse(any(path in {"/v1/traces", "/v1/logs"} for path, _ in PlatformHandler.requests))
|
|
261
|
+
|
|
262
|
+
def test_export_retries_identical_payload_and_does_not_retry_partial_success(self):
|
|
263
|
+
with self.client.run(name="retry"):
|
|
264
|
+
pass
|
|
265
|
+
self.client._registered = True
|
|
266
|
+
attempts = []
|
|
267
|
+
def respond(_method, path, body, **_kwargs):
|
|
268
|
+
attempts.append((path, body))
|
|
269
|
+
if len(attempts) == 1:
|
|
270
|
+
from rippletide.client import RippletideHttpError
|
|
271
|
+
raise RippletideHttpError(503)
|
|
272
|
+
return {"partialSuccess": {"rejectedSpans": "1"}} if path == "/v1/traces" else {}
|
|
273
|
+
with patch.object(self.client, "_request", side_effect=respond):
|
|
274
|
+
self.assertFalse(self.client.flush())
|
|
275
|
+
self.assertEqual(attempts[0], attempts[1])
|
|
276
|
+
self.assertEqual(len(attempts), 3)
|
|
277
|
+
|
|
172
278
|
def test_malformed_or_interrupted_responses_preserve_host_execution(self):
|
|
173
279
|
for failure in (b"<html>unavailable</html>", b"\xff", IncompleteRead(b'{"ok":')):
|
|
174
280
|
with self.subTest(failure=failure):
|
|
@@ -208,7 +314,8 @@ class ClientTests(unittest.TestCase):
|
|
|
208
314
|
request = next(body for path, body in PlatformHandler.requests if path.startswith("/v1/policy/decide"))
|
|
209
315
|
receipt_path, receipt = next((path, body) for path, body in PlatformHandler.requests if path.endswith("/receipt"))
|
|
210
316
|
decision_id = receipt_path.split("/")[-2]
|
|
211
|
-
|
|
317
|
+
self.client.flush()
|
|
318
|
+
events = [event for event in runtime_facts(PlatformHandler.requests) if event["name"] in {policy_name, action_name}]
|
|
212
319
|
self.assertEqual(len(events), 4)
|
|
213
320
|
for event in events:
|
|
214
321
|
self.assertEqual(event["invocationId"], request["invocationId"])
|
|
@@ -286,7 +393,7 @@ class ClientTests(unittest.TestCase):
|
|
|
286
393
|
self.assertTrue(self.client.flush())
|
|
287
394
|
receipt = next(body for path, body in PlatformHandler.requests if path.endswith("/receipt"))
|
|
288
395
|
self.assertEqual(receipt["outcome"], "RESULT_FILTERED")
|
|
289
|
-
batch =
|
|
396
|
+
batch = {"events": runtime_facts(PlatformHandler.requests)}
|
|
290
397
|
names = {(event["kind"], event["name"]) for event in batch["events"]}
|
|
291
398
|
self.assertIn(("policy", "after_action:catalog/list_products"), names)
|
|
292
399
|
self.assertIn(("operation", "result_application"), names)
|
|
@@ -335,7 +442,7 @@ class ClientTests(unittest.TestCase):
|
|
|
335
442
|
headers = PlatformHandler.headers_by_path["/v1/messages"]
|
|
336
443
|
self.assertEqual(headers["anthropic-version"], "2023-06-01")
|
|
337
444
|
self.assertEqual(headers["x-api-key"], "not-a-real-key")
|
|
338
|
-
batch =
|
|
445
|
+
batch = {"events": runtime_facts(PlatformHandler.requests)}
|
|
339
446
|
names = {(event["kind"], event["name"]) for event in batch["events"]}
|
|
340
447
|
self.assertIn(("model", "anthropic.messages.create"), names)
|
|
341
448
|
self.assertIn(("operation", "response_delivery"), names)
|
|
File without changes
|
{rippletide_python-0.1.0 → rippletide_python-0.2.0}/rippletide_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{rippletide_python-0.1.0 → rippletide_python-0.2.0}/rippletide_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|