orithos-prela 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.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: orithos-prela
3
+ Version: 0.2.0
4
+ Summary: Fire-and-forget trace ingestion SDK for Orithos AI Agent Security
5
+ Author-email: Orithos <dev@orithos.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://orithos.com
8
+ Project-URL: Repository, https://github.com/nishkmg/TraceShield
9
+ Project-URL: Documentation, https://github.com/nishkmg/TraceShield/tree/staging/prela-sdk
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: httpx>=0.27.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
23
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
24
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
25
+
26
+ # Prela SDK
27
+
28
+ Fire-and-forget trace ingestion SDK for [TraceShield](https://traceshield.dev).
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install prela
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ from prela import Prela
40
+
41
+ prela = Prela(
42
+ api_key="ts_ingest_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
43
+ endpoint="https://api.traceshield.example.com",
44
+ )
45
+
46
+ # Manual trace
47
+ prela.trace(
48
+ input="What is my account balance?",
49
+ output="I don't have access to account information.",
50
+ )
51
+
52
+ # OpenAI integration
53
+ from prela.integrations.openai import PrelaOpenAI
54
+ from openai import OpenAI
55
+
56
+ client = PrelaOpenAI(OpenAI(), prela)
57
+ response = client.chat.completions.create(
58
+ model="gpt-4o",
59
+ messages=[{"role": "user", "content": "Hello"}],
60
+ )
61
+
62
+ # Before process exit
63
+ prela.shutdown()
64
+ ```
65
+
66
+ ## Design
67
+
68
+ - **Fire-and-forget**: Never blocks the agent's response path
69
+ - **Zero dependencies**: Only requires `httpx`
70
+ - **Batching**: Buffers traces and sends every 500ms or 50 traces
71
+ - **Resilient**: Drops traces on failure, logs warnings, never raises
72
+
73
+ ## Telemetry
74
+
75
+ Send runtime events from your AI agent:
76
+
77
+ ```python
78
+ import prela
79
+
80
+ # Configure once
81
+ prela.configure_telemetry(
82
+ api_key="ts-key-...",
83
+ org_id="org-...",
84
+ agent_id="agent-...",
85
+ )
86
+
87
+ # Track events throughout your code
88
+ prela.track("llm_call", {"model": "gpt-4", "tokens": 150, "latency_ms": 1200})
89
+ prela.track("policy_violation", {"rule": "R-001", "input": "..."}, severity="warning")
90
+ prela.track("error", {"message": "Timeout connecting to API"}, severity="error")
91
+
92
+ # Flush before exit
93
+ prela.flush()
94
+ ```
95
+
96
+ Events are batched (default: every 5s or 10 events) and sent to TraceShield's telemetry API.
97
+
98
+ ## License
99
+
100
+ Private. All rights reserved.
@@ -0,0 +1,75 @@
1
+ # Prela SDK
2
+
3
+ Fire-and-forget trace ingestion SDK for [TraceShield](https://traceshield.dev).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install prela
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from prela import Prela
15
+
16
+ prela = Prela(
17
+ api_key="ts_ingest_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
18
+ endpoint="https://api.traceshield.example.com",
19
+ )
20
+
21
+ # Manual trace
22
+ prela.trace(
23
+ input="What is my account balance?",
24
+ output="I don't have access to account information.",
25
+ )
26
+
27
+ # OpenAI integration
28
+ from prela.integrations.openai import PrelaOpenAI
29
+ from openai import OpenAI
30
+
31
+ client = PrelaOpenAI(OpenAI(), prela)
32
+ response = client.chat.completions.create(
33
+ model="gpt-4o",
34
+ messages=[{"role": "user", "content": "Hello"}],
35
+ )
36
+
37
+ # Before process exit
38
+ prela.shutdown()
39
+ ```
40
+
41
+ ## Design
42
+
43
+ - **Fire-and-forget**: Never blocks the agent's response path
44
+ - **Zero dependencies**: Only requires `httpx`
45
+ - **Batching**: Buffers traces and sends every 500ms or 50 traces
46
+ - **Resilient**: Drops traces on failure, logs warnings, never raises
47
+
48
+ ## Telemetry
49
+
50
+ Send runtime events from your AI agent:
51
+
52
+ ```python
53
+ import prela
54
+
55
+ # Configure once
56
+ prela.configure_telemetry(
57
+ api_key="ts-key-...",
58
+ org_id="org-...",
59
+ agent_id="agent-...",
60
+ )
61
+
62
+ # Track events throughout your code
63
+ prela.track("llm_call", {"model": "gpt-4", "tokens": 150, "latency_ms": 1200})
64
+ prela.track("policy_violation", {"rule": "R-001", "input": "..."}, severity="warning")
65
+ prela.track("error", {"message": "Timeout connecting to API"}, severity="error")
66
+
67
+ # Flush before exit
68
+ prela.flush()
69
+ ```
70
+
71
+ Events are batched (default: every 5s or 10 events) and sent to TraceShield's telemetry API.
72
+
73
+ ## License
74
+
75
+ Private. All rights reserved.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: orithos-prela
3
+ Version: 0.2.0
4
+ Summary: Fire-and-forget trace ingestion SDK for Orithos AI Agent Security
5
+ Author-email: Orithos <dev@orithos.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://orithos.com
8
+ Project-URL: Repository, https://github.com/nishkmg/TraceShield
9
+ Project-URL: Documentation, https://github.com/nishkmg/TraceShield/tree/staging/prela-sdk
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: httpx>=0.27.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
23
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
24
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
25
+
26
+ # Prela SDK
27
+
28
+ Fire-and-forget trace ingestion SDK for [TraceShield](https://traceshield.dev).
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install prela
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ from prela import Prela
40
+
41
+ prela = Prela(
42
+ api_key="ts_ingest_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
43
+ endpoint="https://api.traceshield.example.com",
44
+ )
45
+
46
+ # Manual trace
47
+ prela.trace(
48
+ input="What is my account balance?",
49
+ output="I don't have access to account information.",
50
+ )
51
+
52
+ # OpenAI integration
53
+ from prela.integrations.openai import PrelaOpenAI
54
+ from openai import OpenAI
55
+
56
+ client = PrelaOpenAI(OpenAI(), prela)
57
+ response = client.chat.completions.create(
58
+ model="gpt-4o",
59
+ messages=[{"role": "user", "content": "Hello"}],
60
+ )
61
+
62
+ # Before process exit
63
+ prela.shutdown()
64
+ ```
65
+
66
+ ## Design
67
+
68
+ - **Fire-and-forget**: Never blocks the agent's response path
69
+ - **Zero dependencies**: Only requires `httpx`
70
+ - **Batching**: Buffers traces and sends every 500ms or 50 traces
71
+ - **Resilient**: Drops traces on failure, logs warnings, never raises
72
+
73
+ ## Telemetry
74
+
75
+ Send runtime events from your AI agent:
76
+
77
+ ```python
78
+ import prela
79
+
80
+ # Configure once
81
+ prela.configure_telemetry(
82
+ api_key="ts-key-...",
83
+ org_id="org-...",
84
+ agent_id="agent-...",
85
+ )
86
+
87
+ # Track events throughout your code
88
+ prela.track("llm_call", {"model": "gpt-4", "tokens": 150, "latency_ms": 1200})
89
+ prela.track("policy_violation", {"rule": "R-001", "input": "..."}, severity="warning")
90
+ prela.track("error", {"message": "Timeout connecting to API"}, severity="error")
91
+
92
+ # Flush before exit
93
+ prela.flush()
94
+ ```
95
+
96
+ Events are batched (default: every 5s or 10 events) and sent to TraceShield's telemetry API.
97
+
98
+ ## License
99
+
100
+ Private. All rights reserved.
@@ -0,0 +1,22 @@
1
+ README.md
2
+ pyproject.toml
3
+ orithos_prela.egg-info/PKG-INFO
4
+ orithos_prela.egg-info/SOURCES.txt
5
+ orithos_prela.egg-info/dependency_links.txt
6
+ orithos_prela.egg-info/requires.txt
7
+ orithos_prela.egg-info/top_level.txt
8
+ prela/__init__.py
9
+ prela/_logging.py
10
+ prela/buffer.py
11
+ prela/client.py
12
+ prela/config.py
13
+ prela/sampling.py
14
+ prela/sender.py
15
+ prela/telemetry.py
16
+ prela/truncation.py
17
+ prela/types.py
18
+ prela/integrations/__init__.py
19
+ prela/integrations/langchain.py
20
+ prela/integrations/openai.py
21
+ prela/tests/__init__.py
22
+ prela/tests/test_client.py
@@ -0,0 +1,6 @@
1
+ httpx>=0.27.0
2
+
3
+ [dev]
4
+ pytest>=8.0.0
5
+ pytest-asyncio>=0.23.0
6
+ ruff>=0.4.0
@@ -0,0 +1,8 @@
1
+ """Prela SDK v2 — Fire-and-forget trace ingestion for AI agent security testing."""
2
+
3
+ from prela.client import Prela
4
+ from prela.telemetry import RuntimeTelemetry, flush, track
5
+ from prela.telemetry import configure as configure_telemetry
6
+
7
+ __version__ = "2.0.0"
8
+ __all__ = ["Prela", "RuntimeTelemetry", "configure_telemetry", "flush", "track"]
@@ -0,0 +1,15 @@
1
+ """Internal logging for Prela SDK.
2
+
3
+ Only logs warnings and errors — never debug info.
4
+ Never logs API keys, tokens, or trace content.
5
+ """
6
+
7
+ import logging
8
+
9
+ logger = logging.getLogger("prela")
10
+ logger.setLevel(logging.WARNING)
11
+
12
+ if not logger.handlers:
13
+ handler = logging.StreamHandler()
14
+ handler.setFormatter(logging.Formatter("prela: %(levelname)s - %(message)s"))
15
+ logger.addHandler(handler)
@@ -0,0 +1,52 @@
1
+ """Thread-safe in-memory trace buffer.
2
+
3
+ Internal operations use threading.Lock() to protect the deque.
4
+ Required because:
5
+ - The main thread (agent code) calls prela.trace() → writes to buffer
6
+ - The background sender thread reads and clears the buffer
7
+ """
8
+
9
+ import threading
10
+ from collections import deque
11
+ from typing import Any
12
+
13
+
14
+ class TraceBuffer:
15
+ """Thread-safe buffer for traces awaiting delivery."""
16
+
17
+ def __init__(self, max_size: int = 500):
18
+ self._buffer: deque = deque(maxlen=max_size)
19
+ self._lock = threading.Lock()
20
+ self._dropped = 0
21
+
22
+ def add(self, trace: dict[str, Any]) -> bool:
23
+ """Add a trace to the buffer. Returns False if buffer was full."""
24
+ with self._lock:
25
+ max_len = self._buffer.maxlen
26
+ if max_len is not None and len(self._buffer) >= max_len:
27
+ self._dropped += 1
28
+ return False
29
+ self._buffer.append(trace)
30
+ return True
31
+
32
+ def drain(self) -> list[dict[str, Any]]:
33
+ """Atomically drain all buffered traces."""
34
+ with self._lock:
35
+ items = list(self._buffer)
36
+ self._buffer.clear()
37
+ return items
38
+
39
+ @property
40
+ def size(self) -> int:
41
+ with self._lock:
42
+ return len(self._buffer)
43
+
44
+ @property
45
+ def is_empty(self) -> bool:
46
+ with self._lock:
47
+ return len(self._buffer) == 0
48
+
49
+ @property
50
+ def dropped_count(self) -> int:
51
+ with self._lock:
52
+ return self._dropped
@@ -0,0 +1,141 @@
1
+ """Prela SDK v2 — Fire-and-forget trace ingestion for AI agents.
2
+
3
+ Usage:
4
+ from prela import Prela
5
+ prela = Prela(api_key="ts_ingest_xxx", traceshield_url="https://api.example.com")
6
+ prela.trace(input="Hello", output="Hi there!")
7
+ prela.shutdown() # call before process exit (atexit registered automatically)
8
+ """
9
+
10
+ import atexit
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from typing import Any
15
+
16
+ from prela._logging import logger
17
+ from prela.buffer import TraceBuffer
18
+ from prela.config import PrelaConfig
19
+ from prela.sampling import should_sample
20
+ from prela.truncation import truncate_trace
21
+
22
+
23
+ class Prela:
24
+ """Main SDK class for trace ingestion.
25
+
26
+ Thread-safe. Safe for use in multi-threaded and async contexts.
27
+ Registers an atexit handler automatically to flush remaining traces.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ api_key: str,
33
+ traceshield_url: str = "https://api.traceshield.dev",
34
+ **kwargs: Any,
35
+ ):
36
+ self._config = PrelaConfig(
37
+ api_key=api_key,
38
+ traceshield_url=traceshield_url,
39
+ **kwargs,
40
+ )
41
+ self._buffer = TraceBuffer(max_size=self._config.max_buffer)
42
+ self._lock = threading.Lock()
43
+ self._flush_thread = threading.Thread(
44
+ target=self._flush_loop, daemon=True, name="prela-flush"
45
+ )
46
+ self._flush_thread.start()
47
+
48
+ # Register atexit handler
49
+ atexit.register(self._atexit_flush)
50
+
51
+ if not self._config.enabled:
52
+ logger.info("Prela tracing is disabled")
53
+
54
+ def trace(
55
+ self,
56
+ input: str,
57
+ output: str,
58
+ metadata: dict[str, Any] | None = None,
59
+ tags: list | None = None,
60
+ conversation_history: list | None = None,
61
+ tool_calls: list | None = None,
62
+ ) -> str:
63
+ """Record a trace. Returns trace_id (UUID4).
64
+
65
+ Thread-safe. Safe to call from sync or async code.
66
+ """
67
+ if not self._config.enabled:
68
+ return ""
69
+
70
+ # Sampling check
71
+ if not should_sample(input, self._config.sample_rate):
72
+ return ""
73
+
74
+ trace_id = str(uuid.uuid4())
75
+ trace: dict[str, Any] = {
76
+ "trace_id": trace_id,
77
+ "agent_id": self._config.agent_id,
78
+ "input": input,
79
+ "output": output,
80
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
81
+ }
82
+
83
+ if self._config.pii_redact:
84
+ trace["input"] = self._config.pii_redact(trace["input"])
85
+ trace["output"] = self._config.pii_redact(trace["output"])
86
+
87
+ if self._config.include_conversation and conversation_history:
88
+ trace["conversation_history"] = conversation_history
89
+ if self._config.pii_redact:
90
+ for msg in trace["conversation_history"]:
91
+ if "content" in msg:
92
+ msg["content"] = self._config.pii_redact(msg["content"])
93
+
94
+ if self._config.include_tool_calls and tool_calls:
95
+ trace["tool_calls"] = tool_calls
96
+
97
+ if self._config.include_metadata:
98
+ meta = metadata or {}
99
+ meta["sampled"] = True
100
+ trace["metadata"] = meta
101
+
102
+ if tags:
103
+ trace["tags"] = tags
104
+
105
+ # Truncate if needed
106
+ trace = truncate_trace(trace, self._config.max_trace_size_bytes)
107
+
108
+ added = self._buffer.add(trace)
109
+ if not added and self._config.on_buffer_full:
110
+ self._config.on_buffer_full()
111
+
112
+ return trace_id
113
+
114
+ def flush(self) -> None:
115
+ """Flush all buffered traces immediately (blocking)."""
116
+ # Implemented in the background thread via _flush_loop
117
+ # This method just waits for the next flush cycle
118
+ pass
119
+
120
+ def shutdown(self, timeout: float = 5.0) -> None:
121
+ """Flush remaining traces and stop background thread."""
122
+ self._config.enabled = False
123
+ # The flush thread will drain and exit on next cycle
124
+ self._flush_thread.join(timeout=timeout)
125
+
126
+ def _flush_loop(self) -> None:
127
+ """Background thread: flushes buffer every flush_interval."""
128
+ # TODO: Import and use sender here once async integration is set up
129
+ # For now, this is a placeholder that drains the buffer periodically
130
+ while self._config.enabled:
131
+ time.sleep(self._config.flush_interval)
132
+ traces = self._buffer.drain()
133
+ if traces:
134
+ logger.debug(f"Flushing {len(traces)} traces (sender not yet implemented)")
135
+
136
+ def _atexit_flush(self) -> None:
137
+ """Called on process exit — flushes with 5s timeout."""
138
+ self._config.enabled = False
139
+ traces = self._buffer.drain()
140
+ if traces:
141
+ logger.info(f"atexit: flushing {len(traces)} remaining traces")
@@ -0,0 +1,58 @@
1
+ """Configuration validation and defaults for Prela SDK v2."""
2
+
3
+ import warnings
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class PrelaConfig:
10
+ """SDK configuration with validation."""
11
+
12
+ api_key: str
13
+ traceshield_url: str = "https://api.traceshield.dev"
14
+ agent_id: str = ""
15
+ batch_size: int = 50
16
+ flush_interval: float = 0.5
17
+ max_buffer: int = 500
18
+ max_trace_size_bytes: int = 512_000 # 512KB
19
+ timeout: float = 10.0
20
+ include_conversation: bool = True
21
+ include_tool_calls: bool = True
22
+ include_metadata: bool = True
23
+ sample_rate: float = 1.0
24
+ pii_redact: Callable[[str], str] | None = None
25
+ on_error: Callable[[Exception], None] | None = None
26
+ on_buffer_full: Callable[[], None] | None = None
27
+ enabled: bool = True
28
+
29
+ # Backward compatibility — v1 used 'endpoint'
30
+ endpoint: str | None = field(default=None, repr=False)
31
+
32
+ def __post_init__(self):
33
+ if not self.api_key.startswith("ts_ingest_"):
34
+ raise ValueError("API key must start with 'ts_ingest_'")
35
+
36
+ # v2: 'endpoint' renamed to 'traceshield_url'
37
+ if self.endpoint is not None:
38
+ warnings.warn(
39
+ "The 'endpoint' parameter is deprecated. Use 'traceshield_url' instead.",
40
+ DeprecationWarning,
41
+ stacklevel=2,
42
+ )
43
+ if self.traceshield_url == "https://api.traceshield.dev":
44
+ self.traceshield_url = self.endpoint
45
+ else:
46
+ self.endpoint = self.traceshield_url
47
+
48
+ if not self.traceshield_url.startswith("https://"):
49
+ raise ValueError("traceshield_url must use HTTPS")
50
+
51
+ if self.batch_size < 1 or self.batch_size > 100:
52
+ raise ValueError("batch_size must be between 1 and 100")
53
+
54
+ if not (0.0 <= self.sample_rate <= 1.0):
55
+ raise ValueError("sample_rate must be between 0.0 and 1.0")
56
+
57
+ if self.max_trace_size_bytes < 1024:
58
+ raise ValueError("max_trace_size_bytes must be at least 1024")
@@ -0,0 +1 @@
1
+ # Integrations
@@ -0,0 +1,17 @@
1
+ """LangChain callback handler for automatic trace capture.
2
+
3
+ Usage:
4
+ from prela.integrations.langchain import PrelaCallback
5
+
6
+ callback = PrelaCallback(prela=prela)
7
+ chain = LLMChain(llm=llm, prompt=prompt, callbacks=[callback])
8
+ """
9
+
10
+ from prela.client import Prela
11
+
12
+
13
+ class PrelaCallback:
14
+ """LangChain callback handler that captures traces."""
15
+
16
+ def __init__(self, prela: Prela):
17
+ self._prela = prela
@@ -0,0 +1,30 @@
1
+ """OpenAI client wrapper for automatic trace capture.
2
+
3
+ Usage:
4
+ from prela.integrations.openai import PrelaOpenAI
5
+ from openai import OpenAI
6
+
7
+ prela = Prela(api_key="ts_ingest_xxx")
8
+ client = PrelaOpenAI(OpenAI(), prela)
9
+
10
+ response = client.chat.completions.create(
11
+ model="gpt-4o",
12
+ messages=[{"role": "user", "content": "Hello"}],
13
+ )
14
+ # Trace is automatically sent to Orithos
15
+ """
16
+
17
+ from typing import Any
18
+
19
+ from prela.client import Prela
20
+
21
+
22
+ class PrelaOpenAI:
23
+ """Wraps OpenAI client to auto-capture traces."""
24
+
25
+ def __init__(self, openai_client: Any, prela: Prela):
26
+ self._client = openai_client
27
+ self._prela = prela
28
+
29
+ def __getattr__(self, name: str) -> Any:
30
+ return getattr(self._client, name)
@@ -0,0 +1,30 @@
1
+ """Deterministic sampling by input hash.
2
+
3
+ Sampling is deterministic — same input always makes the same
4
+ sample/skip decision. This ensures test results are reproducible.
5
+ """
6
+
7
+ import hashlib
8
+
9
+
10
+ def should_sample(input_text: str, sample_rate: float) -> bool:
11
+ """Determine whether to sample this trace.
12
+
13
+ Args:
14
+ input_text: The trace input text.
15
+ sample_rate: Float between 0.0 and 1.0.
16
+
17
+ Returns:
18
+ True if the trace should be sent, False if it should be dropped.
19
+ """
20
+ if sample_rate >= 1.0:
21
+ return True
22
+ if sample_rate <= 0.0:
23
+ return False
24
+
25
+ # Hash the input to get a deterministic value between 0 and 1
26
+ hash_hex = hashlib.sha256(input_text.encode("utf-8")).hexdigest()
27
+ hash_int = int(hash_hex[:8], 16) # First 8 hex chars = 32 bits
28
+ normalized = hash_int / 0xFFFFFFFF
29
+
30
+ return normalized < sample_rate
@@ -0,0 +1,116 @@
1
+ """Background HTTP sender with retry logic and 401 recovery.
2
+
3
+ Sends buffered traces to Orithos API. Never raises —
4
+ logs warnings on failure and drops traces.
5
+
6
+ v2: 401 no longer stops permanently. Uses graduated escalation:
7
+ 60s pause → retry → 300s pause → retry → 3rd failure = permanent stop.
8
+ """
9
+
10
+ import asyncio
11
+ import logging
12
+
13
+ import httpx
14
+
15
+ logger = logging.getLogger("prela.sender")
16
+
17
+
18
+ class TraceSender:
19
+ """Sends traces to Orithos API in the background."""
20
+
21
+ def __init__(
22
+ self,
23
+ traceshield_url: str,
24
+ api_key: str,
25
+ timeout: float = 10.0,
26
+ on_error=None,
27
+ ):
28
+ self._traceshield_url = traceshield_url
29
+ self._api_key = api_key
30
+ self._timeout = timeout
31
+ self._client = httpx.AsyncClient(timeout=timeout)
32
+ self._stopped = False
33
+ self._on_error = on_error
34
+
35
+ # v2: 401 recovery state
36
+ self._consecutive_401s = 0
37
+ self._pause_until = 0.0 # Unix timestamp
38
+
39
+ async def send_batch(self, traces: list[dict]) -> None:
40
+ """Send a batch of traces. Fire-and-forget — never raises."""
41
+ if not traces or self._stopped:
42
+ return
43
+
44
+ try:
45
+ response = await self._client.post(
46
+ f"{self._traceshield_url}/v1/traces/batch",
47
+ json={"traces": traces},
48
+ headers={
49
+ "Authorization": f"Bearer {self._api_key}",
50
+ "X-Prela-SDK-Version": "2.0.0",
51
+ "X-Prela-SDK-Language": "python",
52
+ },
53
+ )
54
+
55
+ if response.status_code == 202:
56
+ self._consecutive_401s = 0 # Reset on success
57
+ return
58
+
59
+ if response.status_code == 401:
60
+ await self._handle_401()
61
+ return
62
+
63
+ if response.status_code == 413:
64
+ # Payload too large — split batch and retry
65
+ if len(traces) > 1:
66
+ mid = len(traces) // 2
67
+ await self.send_batch(traces[:mid])
68
+ await self.send_batch(traces[mid:])
69
+ else:
70
+ logger.warning("Single trace too large — dropping")
71
+ return
72
+
73
+ if response.status_code == 429:
74
+ retry_after = int(response.headers.get("Retry-After", 60))
75
+ logger.warning(f"Rate limited — backing off for {retry_after}s")
76
+ await asyncio.sleep(retry_after)
77
+ return
78
+
79
+ if response.status_code >= 500:
80
+ logger.warning(f"Server error ({response.status_code}) — dropping traces")
81
+ return
82
+
83
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
84
+ logger.warning(f"Network error — dropping traces: {e}")
85
+ except Exception as e:
86
+ logger.warning(f"Unexpected error — dropping traces: {e}")
87
+ if self._on_error:
88
+ self._on_error(e)
89
+
90
+ async def _handle_401(self) -> None:
91
+ """Graduated 401 escalation — v2 behavior."""
92
+ self._consecutive_401s += 1
93
+ key_prefix = self._api_key[:16] + "..."
94
+
95
+ if self._consecutive_401s == 1:
96
+ logger.warning(f"Auth error ({key_prefix}) — pausing 60s, will retry")
97
+ await asyncio.sleep(60)
98
+ elif self._consecutive_401s == 2:
99
+ logger.warning(f"Auth error again ({key_prefix}) — pausing 300s, will retry")
100
+ await asyncio.sleep(300)
101
+ else:
102
+ logger.critical(
103
+ f"Auth error 3 times ({key_prefix}) — key is invalid, stopping permanently"
104
+ )
105
+ self._stopped = True
106
+ if self._on_error:
107
+ self._on_error(Exception("Invalid API key after 3 retries"))
108
+
109
+ async def close(self) -> None:
110
+ """Close the HTTP client."""
111
+ self._stopped = True
112
+ await self._client.aclose()
113
+
114
+ @property
115
+ def stopped(self) -> bool:
116
+ return self._stopped
@@ -0,0 +1,214 @@
1
+ """Telemetry module for Prela SDK — fire-and-forget runtime event submission.
2
+
3
+ Module-level API (existing):
4
+ configure(api_key=..., api_url=...)
5
+ track("llm_call", payload={...})
6
+ flush()
7
+
8
+ OOP API (new):
9
+ rt = RuntimeTelemetry(api_key="tsk_...")
10
+ rt.capture("db.query", agent_id="agent-1")
11
+ rt.flush()
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import threading
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+ from typing import Any
20
+ from urllib.error import URLError
21
+ from urllib.request import Request, urlopen
22
+
23
+ _API_URL = os.environ.get("TRACESHIELD_API_URL", "https://api.traceshield.io")
24
+ _API_KEY = os.environ.get("TRACESHIELD_API_KEY", "")
25
+ _ORG_ID = os.environ.get("TRACESHIELD_ORG_ID", "")
26
+ _AGENT_ID = os.environ.get("TRACESHIELD_AGENT_ID", "")
27
+ _BATCH_SIZE = int(os.environ.get("TRACESHIELD_TELEMETRY_BATCH_SIZE", "10"))
28
+ _FLUSH_INTERVAL = float(os.environ.get("TRACESHIELD_TELEMETRY_FLUSH_INTERVAL", "5.0"))
29
+
30
+ _buffer: list[dict[str, Any]] = []
31
+ _lock = threading.Lock()
32
+ _timer: threading.Timer | None = None
33
+
34
+
35
+ def _get_session_id() -> str:
36
+ """Get or create a persistent session ID for this process."""
37
+ sid = os.environ.get("TRACESHIELD_SESSION_ID", "")
38
+ if not sid:
39
+ sid = str(uuid.uuid4())
40
+ os.environ["TRACESHIELD_SESSION_ID"] = sid
41
+ return sid
42
+
43
+
44
+ def _flush() -> None:
45
+ """Send buffered events to the API."""
46
+ global _timer, _buffer
47
+ with _lock:
48
+ if not _buffer:
49
+ return
50
+ batch = _buffer
51
+ _buffer = []
52
+
53
+ if not _API_KEY or not _ORG_ID:
54
+ return # Silent no-op if not configured
55
+
56
+ try:
57
+ data = json.dumps({"events": batch}).encode()
58
+ req = Request(
59
+ f"{_API_URL.rstrip('/')}/api/v1/telemetry/events",
60
+ data=data,
61
+ headers={
62
+ "Content-Type": "application/json",
63
+ "Authorization": f"Bearer {_API_KEY}",
64
+ "X-Org-Id": _ORG_ID,
65
+ },
66
+ method="POST",
67
+ )
68
+ urlopen(req, timeout=5)
69
+ except URLError:
70
+ pass # Fire-and-forget: silently drop on network failure
71
+ except Exception:
72
+ pass
73
+
74
+
75
+ def _schedule_flush() -> None:
76
+ """Schedule flush if not already scheduled."""
77
+ global _timer
78
+ if _timer is not None and _timer.is_alive():
79
+ return
80
+ _timer = threading.Timer(_FLUSH_INTERVAL, _flush)
81
+ _timer.daemon = True
82
+ _timer.start()
83
+
84
+
85
+ def track(
86
+ event_type: str,
87
+ payload: dict[str, Any] | None = None,
88
+ severity: str = "info",
89
+ agent_id: str | None = None,
90
+ session_id: str | None = None,
91
+ trace_id: str | None = None,
92
+ ) -> None:
93
+ """Record a telemetry event. Fire-and-forget — never blocks.
94
+
95
+ Args:
96
+ event_type: Event category (e.g. 'llm_call', 'tool_use', 'policy_violation', 'error')
97
+ payload: Arbitrary event data (will be JSON-serialized)
98
+ severity: 'info', 'warning', 'error', 'critical'
99
+ agent_id: Override auto-detected agent ID
100
+ session_id: Override auto-detected session ID
101
+ trace_id: Correlate events across a request trace
102
+ """
103
+ event = {
104
+ "agent_id": agent_id or _AGENT_ID,
105
+ "session_id": session_id or _get_session_id(),
106
+ "event_type": event_type,
107
+ "severity": severity,
108
+ "payload": payload or {},
109
+ "trace_id": trace_id or "",
110
+ "timestamp": datetime.now(timezone.utc).isoformat(),
111
+ }
112
+ with _lock:
113
+ _buffer.append(event)
114
+ if len(_buffer) >= _BATCH_SIZE:
115
+ _flush()
116
+ return
117
+ _schedule_flush()
118
+
119
+
120
+ def flush() -> None:
121
+ """Force-flush any buffered events. Call before process exit."""
122
+ global _timer
123
+ if _timer is not None:
124
+ _timer.cancel()
125
+ _timer = None
126
+ _flush()
127
+
128
+
129
+ def configure(
130
+ api_url: str | None = None,
131
+ api_key: str | None = None,
132
+ org_id: str | None = None,
133
+ agent_id: str | None = None,
134
+ batch_size: int | None = None,
135
+ flush_interval: float | None = None,
136
+ ) -> None:
137
+ """Configure telemetry settings programmatically (overrides env vars)."""
138
+ global _API_URL, _API_KEY, _ORG_ID, _AGENT_ID, _BATCH_SIZE, _FLUSH_INTERVAL
139
+ if api_url is not None:
140
+ _API_URL = api_url
141
+ if api_key is not None:
142
+ _API_KEY = api_key
143
+ if org_id is not None:
144
+ _ORG_ID = org_id
145
+ if agent_id is not None:
146
+ _AGENT_ID = agent_id
147
+ if batch_size is not None:
148
+ _BATCH_SIZE = batch_size
149
+ if flush_interval is not None:
150
+ _FLUSH_INTERVAL = flush_interval
151
+
152
+
153
+ class RuntimeTelemetry:
154
+ """OOP wrapper around the module-level telemetry API.
155
+
156
+ Fire-and-forget event capture for production agent monitoring.
157
+ Never blocks or raises — silently drops on network failure.
158
+
159
+ Usage:
160
+ rt = RuntimeTelemetry(api_key="tsk_...", agent_id="agent-abc")
161
+ rt.capture("db.query", payload={"table": "users"})
162
+ rt.capture("llm.chat", severity="warning")
163
+ rt.flush()
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ api_key: str,
169
+ api_url: str | None = None,
170
+ agent_id: str | None = None,
171
+ org_id: str | None = None,
172
+ batch_size: int | None = None,
173
+ flush_interval: float | None = None,
174
+ ):
175
+ configure(
176
+ api_key=api_key,
177
+ api_url=api_url,
178
+ agent_id=agent_id,
179
+ org_id=org_id,
180
+ batch_size=batch_size,
181
+ flush_interval=flush_interval,
182
+ )
183
+
184
+ def capture(
185
+ self,
186
+ event_type: str,
187
+ agent_id: str | None = None,
188
+ severity: str = "info",
189
+ payload: dict[str, Any] | None = None,
190
+ session_id: str | None = None,
191
+ trace_id: str | None = None,
192
+ ) -> None:
193
+ """Record a runtime telemetry event. Fire-and-forget.
194
+
195
+ Args:
196
+ event_type: Event category (e.g. 'db.query', 'llm.chat', 'api.call', 'memory.read')
197
+ agent_id: Override the agent ID configured at init
198
+ severity: 'info', 'warning', 'error', 'critical'
199
+ payload: Arbitrary event data (will be JSON-serialized)
200
+ session_id: Override auto-detected session ID
201
+ trace_id: Correlate events across a request trace
202
+ """
203
+ track(
204
+ event_type=event_type,
205
+ payload=payload,
206
+ severity=severity,
207
+ agent_id=agent_id,
208
+ session_id=session_id,
209
+ trace_id=trace_id,
210
+ )
211
+
212
+ def flush(self) -> None:
213
+ """Force-flush buffered events."""
214
+ flush()
@@ -0,0 +1 @@
1
+ # SDK tests
@@ -0,0 +1,25 @@
1
+ """Tests for Prela SDK client."""
2
+
3
+ import pytest
4
+
5
+ from prela.config import PrelaConfig
6
+
7
+
8
+ def test_config_validates_api_key():
9
+ """API key must start with ts_ingest_."""
10
+ with pytest.raises(ValueError):
11
+ PrelaConfig(api_key="invalid_key")
12
+
13
+
14
+ def test_config_validates_endpoint():
15
+ """Endpoint must use HTTPS."""
16
+ with pytest.raises(ValueError):
17
+ PrelaConfig(api_key="ts_ingest_xxx", endpoint="http://example.com")
18
+
19
+
20
+ def test_config_defaults():
21
+ """Config should have sensible defaults."""
22
+ config = PrelaConfig(api_key="ts_ingest_xxx")
23
+ assert config.endpoint == "https://api.traceshield.dev"
24
+ assert config.batch_size == 50
25
+ assert config.flush_interval == 0.5
@@ -0,0 +1,69 @@
1
+ """Payload size enforcement and truncation logic.
2
+
3
+ When a trace exceeds max_trace_size_bytes (default 512KB),
4
+ the SDK truncates in this order:
5
+ 1. conversation_history: Remove oldest messages first
6
+ 2. output: Truncate to 10,000 chars
7
+ 3. tool_calls: Remove tool call outputs
8
+ 4. input: Truncate to 5,000 chars (never fully removed)
9
+ """
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ MAX_OUTPUT_CHARS = 10_000
15
+ MAX_INPUT_CHARS = 5_000
16
+ MIN_CONVERSATION_TURNS = 3 # Always keep system prompt + last N turns
17
+
18
+
19
+ def truncate_trace(trace: dict[str, Any], max_bytes: int) -> dict[str, Any]:
20
+ """Truncate trace to fit within max_bytes. Returns modified trace."""
21
+ trace = trace.copy()
22
+ metadata = trace.get("metadata", {})
23
+
24
+ if _estimate_size(trace) <= max_bytes:
25
+ return trace
26
+
27
+ # 1. Remove oldest conversation messages
28
+ conv = trace.get("conversation_history", [])
29
+ if len(conv) > MIN_CONVERSATION_TURNS:
30
+ system_msg = [m for m in conv if m.get("role") == "system"]
31
+ recent = conv[-MIN_CONVERSATION_TURNS:]
32
+ trace["conversation_history"] = system_msg + recent
33
+ if _estimate_size(trace) <= max_bytes:
34
+ metadata["truncated"] = True
35
+ trace["metadata"] = metadata
36
+ return trace
37
+
38
+ # 2. Truncate output
39
+ output = trace.get("output", "")
40
+ if len(output) > MAX_OUTPUT_CHARS:
41
+ trace["output"] = output[:MAX_OUTPUT_CHARS] + " [TRUNCATED]"
42
+ if _estimate_size(trace) <= max_bytes:
43
+ metadata["truncated"] = True
44
+ trace["metadata"] = metadata
45
+ return trace
46
+
47
+ # 3. Remove tool call outputs
48
+ tool_calls = trace.get("tool_calls", [])
49
+ if tool_calls:
50
+ for tc in tool_calls:
51
+ tc.pop("output", None)
52
+ if _estimate_size(trace) <= max_bytes:
53
+ metadata["truncated"] = True
54
+ trace["metadata"] = metadata
55
+ return trace
56
+
57
+ # 4. Truncate input (never fully removed)
58
+ inp = trace.get("input", "")
59
+ if len(inp) > MAX_INPUT_CHARS:
60
+ trace["input"] = inp[:MAX_INPUT_CHARS] + " [TRUNCATED]"
61
+
62
+ metadata["truncated"] = True
63
+ trace["metadata"] = metadata
64
+ return trace
65
+
66
+
67
+ def _estimate_size(trace: dict[str, Any]) -> int:
68
+ """Estimate JSON-serialized size of trace in bytes."""
69
+ return len(json.dumps(trace, ensure_ascii=False).encode("utf-8"))
@@ -0,0 +1,33 @@
1
+ """TypedDicts for trace data structures — v2."""
2
+
3
+ from typing import Any, TypedDict
4
+
5
+
6
+ class ToolCallData(TypedDict, total=False):
7
+ tool: str
8
+ input: dict[str, Any]
9
+ output: dict[str, Any]
10
+ latency_ms: int
11
+
12
+
13
+ class TraceMetadata(TypedDict, total=False):
14
+ model: str
15
+ temperature: float
16
+ tokens_input: int
17
+ tokens_output: int
18
+ latency_ms: int
19
+ cost_usd: float
20
+ truncated: bool # v2: True if trace was truncated due to size
21
+
22
+
23
+ class TraceData(TypedDict, total=False):
24
+ trace_id: str # v2: Client-generated UUID4 for deduplication
25
+ agent_id: str
26
+ input: str
27
+ output: str
28
+ conversation_history: list[dict[str, str]]
29
+ tool_calls: list[ToolCallData]
30
+ metadata: TraceMetadata
31
+ tags: list[str]
32
+ sampled: bool # v2: Always True (unsampled traces aren't sent)
33
+ timestamp: str
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0.0", "wheel>=0.42.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "orithos-prela"
7
+ version = "0.2.0"
8
+ description = "Fire-and-forget trace ingestion SDK for Orithos AI Agent Security"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Orithos", email = "dev@orithos.com"},
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://orithos.com"
31
+ Repository = "https://github.com/nishkmg/TraceShield"
32
+ Documentation = "https://github.com/nishkmg/TraceShield/tree/staging/prela-sdk"
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8.0.0",
37
+ "pytest-asyncio>=0.23.0",
38
+ "ruff>=0.4.0",
39
+ ]
40
+
41
+ [tool.setuptools.packages.find]
42
+ include = ["prela*"]
43
+
44
+ [tool.ruff]
45
+ target-version = "py310"
46
+ line-length = 100
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "RUF"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["prela/tests"]
52
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+