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.
- orithos_prela-0.2.0/PKG-INFO +100 -0
- orithos_prela-0.2.0/README.md +75 -0
- orithos_prela-0.2.0/orithos_prela.egg-info/PKG-INFO +100 -0
- orithos_prela-0.2.0/orithos_prela.egg-info/SOURCES.txt +22 -0
- orithos_prela-0.2.0/orithos_prela.egg-info/dependency_links.txt +1 -0
- orithos_prela-0.2.0/orithos_prela.egg-info/requires.txt +6 -0
- orithos_prela-0.2.0/orithos_prela.egg-info/top_level.txt +1 -0
- orithos_prela-0.2.0/prela/__init__.py +8 -0
- orithos_prela-0.2.0/prela/_logging.py +15 -0
- orithos_prela-0.2.0/prela/buffer.py +52 -0
- orithos_prela-0.2.0/prela/client.py +141 -0
- orithos_prela-0.2.0/prela/config.py +58 -0
- orithos_prela-0.2.0/prela/integrations/__init__.py +1 -0
- orithos_prela-0.2.0/prela/integrations/langchain.py +17 -0
- orithos_prela-0.2.0/prela/integrations/openai.py +30 -0
- orithos_prela-0.2.0/prela/sampling.py +30 -0
- orithos_prela-0.2.0/prela/sender.py +116 -0
- orithos_prela-0.2.0/prela/telemetry.py +214 -0
- orithos_prela-0.2.0/prela/tests/__init__.py +1 -0
- orithos_prela-0.2.0/prela/tests/test_client.py +25 -0
- orithos_prela-0.2.0/prela/truncation.py +69 -0
- orithos_prela-0.2.0/prela/types.py +33 -0
- orithos_prela-0.2.0/pyproject.toml +52 -0
- orithos_prela-0.2.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prela
|
|
@@ -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"
|