rippletide-python 0.1.0__py3-none-any.whl → 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.
rippletide/client.py CHANGED
@@ -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
- _CURRENT: ContextVar[dict[str, str] | None] = ContextVar("rippletide_current_span", default=None)
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
- parent = _CURRENT.get()
246
- inbound = _TRACEPARENT.match(traceparent or "") if root else None
247
- self.trace_id = inbound.group(1).lower() if inbound else parent["trace_id"] if parent else _hex(16)
248
- self.span_id = _hex(8)
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
- self.token = _CURRENT.set({"trace_id": self.trace_id, "span_id": self.span_id, "trace_flags": "01"})
257
- self.client._record("span_start", self, input=self.options.get("input"))
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
- if exc:
266
- self.client._record("span_end", self, status="error", error=exc)
267
- else:
268
- self.client._record("span_end", self, status="ok", output=self.output if self.output_set else None, attributes=self.end_attributes)
269
- if self.token is not None:
270
- _CURRENT.reset(self.token)
271
- self.ended = True
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, dependency-free client with the TypeScript SDK's core semantics."""
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, capture_mode: str | None = None, disabled: bool | None = None, timeout_ms: int | None = None, warn: Callable[[str], None] | None = 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.capture_mode = capture_mode or os.getenv("RIPPLETIDE_CAPTURE_MODE", "metadata")
284
- self.capture_mode = self.capture_mode if self.capture_mode in {"metadata", "redacted", "full"} else "metadata"
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.events: list[dict[str, Any]] = []
276
+ self._telemetry: Telemetry | None = None
289
277
  self._registered = False
290
- self._warn = warn or (lambda _message: None)
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 = None, conversation_id: str | None = None, traceparent: str | None = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
357
- with _Span(self, "run", name, root=True, traceparent=traceparent, input=input, conversationId=conversation_id, attributes=attributes) as span:
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 = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
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 = None, tool_call_id: str | None = None, invocation_id: str | None = None, decision_id: str | None = None, attributes: Mapping[str, Any] | None = None) -> Iterator[_Span]:
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
- current = _CURRENT.get()
383
- return {"traceparent": f"00-{current['trace_id']}-{current['span_id']}-{current['trace_flags']}"} if current else {}
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": _safe(fallback) if export_content else _withheld_prompt(prompt_type), "config": options.get("config", {}), "source": "sdk", "contentExported": export_content})
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.events:
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
- all_sent = True
647
- while self.events:
648
- batch, self.events = self.events[:50], self.events[50:]
649
- try:
650
- self._request("POST", f"/v1/runtime/traces/{quote(self.agent_id, safe='')}", {"schemaVersion": "runtime_trace.v1", "events": batch})
651
- except RippletideHttpError:
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.1.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
- The client starts disabled when no Connection key is present. Network, 5xx, and
52
- rate-limit failures fail open; a valid enforce-mode policy `BLOCK` is the only
53
- case that prevents a protected callback. Runtime capture defaults to metadata;
54
- use `RIPPLETIDE_CAPTURE_MODE=redacted` or explicitly opt into `full` when the
55
- privacy posture allows it. Always call `flush()` before a short-lived worker,
56
- CLI, or serverless invocation exits.
57
-
58
- Malformed or interrupted HTTP responses also fail open.
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
- metadata capture mode. These IDs correlate the action with its policy receipt.
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, and unavailable policy service forwards every item unchanged.
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={
@@ -0,0 +1,7 @@
1
+ rippletide/__init__.py,sha256=lPw2FwwCVSYhMQdQlgZufRLZLZrECKYPvtrMELUxYdI,304
2
+ rippletide/client.py,sha256=gAxI38pg9a1kNvyV6s_lmfauCNgeGrwOXXM-CEz-cl8,36623
3
+ rippletide/telemetry.py,sha256=oG6YaRZLwUvIshl3egXeMN5ViD5mDMMVWClCvH3nnM0,13729
4
+ rippletide_python-0.2.0.dist-info/METADATA,sha256=k8jWdR_NzOpRbHK4dOhgstK1AiwLsaK65YMcX3gNFoQ,7659
5
+ rippletide_python-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ rippletide_python-0.2.0.dist-info/top_level.txt,sha256=NAEYCFwQDE0G96JlVKq00OiKb0YLZ_Er8rcAhOiYHzo,11
7
+ rippletide_python-0.2.0.dist-info/RECORD,,
@@ -1,6 +0,0 @@
1
- rippletide/__init__.py,sha256=lPw2FwwCVSYhMQdQlgZufRLZLZrECKYPvtrMELUxYdI,304
2
- rippletide/client.py,sha256=kb1x4AlOx-20ABVpuyEiXAFyq6OmKVnoFlEcA94WKOM,38176
3
- rippletide_python-0.1.0.dist-info/METADATA,sha256=JjBGhn3keRNNGmOUDtT4BtaK0I6Xwtmy3KCREVSZgK8,4704
4
- rippletide_python-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
- rippletide_python-0.1.0.dist-info/top_level.txt,sha256=NAEYCFwQDE0G96JlVKq00OiKb0YLZ_Er8rcAhOiYHzo,11
6
- rippletide_python-0.1.0.dist-info/RECORD,,