agentwatchx 0.1.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.
- agentwatchx/__init__.py +106 -0
- agentwatchx/client.py +90 -0
- agentwatchx/decorators.py +63 -0
- agentwatchx/integrations/__init__.py +59 -0
- agentwatchx/integrations/anthropic_patch.py +247 -0
- agentwatchx/integrations/langchain_cb.py +191 -0
- agentwatchx/integrations/llamaindex_cb.py +175 -0
- agentwatchx/integrations/openai_patch.py +231 -0
- agentwatchx/schemas.py +19 -0
- agentwatchx-0.1.0.dist-info/METADATA +103 -0
- agentwatchx-0.1.0.dist-info/RECORD +14 -0
- agentwatchx-0.1.0.dist-info/WHEEL +5 -0
- agentwatchx-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentwatchx-0.1.0.dist-info/top_level.txt +1 -0
agentwatchx/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""AgentWatchX — Observability SDK for AI Agent Execution."""
|
|
2
|
+
|
|
3
|
+
from agentwatchx.client import AgentWatchXClient
|
|
4
|
+
from agentwatchx.decorators import trace
|
|
5
|
+
from agentwatchx.schemas import TraceData, ToolCallData
|
|
6
|
+
|
|
7
|
+
# Module-level client instance
|
|
8
|
+
_client: AgentWatchXClient | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def init(api_key: str, endpoint: str = "https://api.agentwatchx.com", auto_instrument: bool = True, **kwargs):
|
|
12
|
+
"""Initialize the AgentWatchX SDK.
|
|
13
|
+
|
|
14
|
+
After calling init(), tracing begins automatically for supported libraries
|
|
15
|
+
(OpenAI, Anthropic, LangChain, LlamaIndex) if they are installed.
|
|
16
|
+
|
|
17
|
+
Set auto_instrument=False to disable auto-patching and use manual
|
|
18
|
+
capture/wrap/trace only.
|
|
19
|
+
"""
|
|
20
|
+
global _client
|
|
21
|
+
_client = AgentWatchXClient(api_key=api_key, endpoint=endpoint, **kwargs)
|
|
22
|
+
|
|
23
|
+
if auto_instrument:
|
|
24
|
+
from agentwatchx.integrations import auto_instrument as _auto
|
|
25
|
+
_auto(_client)
|
|
26
|
+
|
|
27
|
+
return _client
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_client() -> AgentWatchXClient:
|
|
31
|
+
if _client is None:
|
|
32
|
+
raise RuntimeError("AgentWatchX SDK not initialized. Call agentwatchx.init() first.")
|
|
33
|
+
return _client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def wrap(llm_client):
|
|
37
|
+
"""Wrap an LLM client instance for explicit trace capture.
|
|
38
|
+
|
|
39
|
+
This is optional when auto-instrumentation is active. Use it when you
|
|
40
|
+
want to trace a specific client instance without global patching.
|
|
41
|
+
|
|
42
|
+
Supported: OpenAI, Anthropic client instances.
|
|
43
|
+
"""
|
|
44
|
+
if _client is None:
|
|
45
|
+
raise RuntimeError("AgentWatchX SDK not initialized. Call agentwatchx.init() first.")
|
|
46
|
+
|
|
47
|
+
# Detect client type and apply instance-level wrapping
|
|
48
|
+
client_type = type(llm_client).__module__
|
|
49
|
+
|
|
50
|
+
if "openai" in client_type:
|
|
51
|
+
from agentwatchx.integrations.openai_patch import patch
|
|
52
|
+
patch(_client)
|
|
53
|
+
elif "anthropic" in client_type:
|
|
54
|
+
from agentwatchx.integrations.anthropic_patch import patch
|
|
55
|
+
patch(_client)
|
|
56
|
+
|
|
57
|
+
return llm_client
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log(data: dict):
|
|
61
|
+
"""Manually log a trace from a dict. Convenience for custom events."""
|
|
62
|
+
if _client is None:
|
|
63
|
+
raise RuntimeError("AgentWatchX SDK not initialized. Call agentwatchx.init() first.")
|
|
64
|
+
|
|
65
|
+
tool_calls = []
|
|
66
|
+
for tc in data.get("tool_calls", []):
|
|
67
|
+
tool_calls.append(ToolCallData(**tc) if isinstance(tc, dict) else tc)
|
|
68
|
+
|
|
69
|
+
trace_data = TraceData(
|
|
70
|
+
service=data.get("service", data.get("type", "custom")),
|
|
71
|
+
input_text=str(data.get("input", data.get("input_text", ""))),
|
|
72
|
+
output_text=str(data.get("output", data.get("output_text", ""))) if data.get("output") or data.get("output_text") else None,
|
|
73
|
+
llm_model=data.get("llm_model", data.get("model")),
|
|
74
|
+
duration_ms=data.get("duration_ms"),
|
|
75
|
+
tool_calls=tool_calls,
|
|
76
|
+
metadata={k: v for k, v in data.items() if k not in {
|
|
77
|
+
"service", "type", "input", "input_text", "output", "output_text",
|
|
78
|
+
"llm_model", "model", "duration_ms", "tool_calls",
|
|
79
|
+
}},
|
|
80
|
+
)
|
|
81
|
+
_client.capture(trace_data)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def shutdown():
|
|
85
|
+
"""Flush pending traces and close the client."""
|
|
86
|
+
if _client:
|
|
87
|
+
from agentwatchx.integrations import unpatch_all
|
|
88
|
+
unpatch_all()
|
|
89
|
+
_client.flush()
|
|
90
|
+
_client.close()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def status() -> dict:
|
|
94
|
+
"""Return SDK connection status."""
|
|
95
|
+
if _client is None:
|
|
96
|
+
return {"initialized": False}
|
|
97
|
+
info = _client.status()
|
|
98
|
+
from agentwatchx.integrations import get_instrumented
|
|
99
|
+
info["instrumented"] = get_instrumented()
|
|
100
|
+
return info
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"init", "trace", "wrap", "log", "shutdown", "status",
|
|
105
|
+
"get_client", "TraceData", "ToolCallData",
|
|
106
|
+
]
|
agentwatchx/client.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import time
|
|
3
|
+
import atexit
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from agentwatchx.schemas import TraceData
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentWatchXClient:
|
|
11
|
+
"""Core SDK client. Buffers traces and flushes in batches."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
api_key: str,
|
|
16
|
+
endpoint: str = "https://api.agentwatchx.com",
|
|
17
|
+
flush_interval: float = 5.0,
|
|
18
|
+
batch_size: int = 100,
|
|
19
|
+
service: str = "default",
|
|
20
|
+
):
|
|
21
|
+
self.api_key = api_key
|
|
22
|
+
self.endpoint = endpoint.rstrip("/")
|
|
23
|
+
self.flush_interval = flush_interval
|
|
24
|
+
self.batch_size = batch_size
|
|
25
|
+
self.default_service = service
|
|
26
|
+
|
|
27
|
+
self._buffer: list[dict] = []
|
|
28
|
+
self._lock = threading.Lock()
|
|
29
|
+
self._http = httpx.Client(
|
|
30
|
+
base_url=self.endpoint,
|
|
31
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
32
|
+
timeout=10.0,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Background flush thread
|
|
36
|
+
self._running = True
|
|
37
|
+
self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
|
|
38
|
+
self._flush_thread.start()
|
|
39
|
+
atexit.register(self.shutdown)
|
|
40
|
+
|
|
41
|
+
def capture(self, trace: TraceData):
|
|
42
|
+
"""Add a trace to the buffer."""
|
|
43
|
+
data = trace.model_dump()
|
|
44
|
+
if data["service"] == "default":
|
|
45
|
+
data["service"] = self.default_service
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._buffer.append(data)
|
|
48
|
+
if len(self._buffer) >= self.batch_size:
|
|
49
|
+
self._send_batch()
|
|
50
|
+
|
|
51
|
+
def flush(self):
|
|
52
|
+
"""Force flush all buffered traces."""
|
|
53
|
+
with self._lock:
|
|
54
|
+
self._send_batch()
|
|
55
|
+
|
|
56
|
+
def close(self):
|
|
57
|
+
"""Stop the flush thread and close HTTP client."""
|
|
58
|
+
self._running = False
|
|
59
|
+
self.flush()
|
|
60
|
+
self._http.close()
|
|
61
|
+
|
|
62
|
+
def shutdown(self):
|
|
63
|
+
"""Alias for close — used by atexit."""
|
|
64
|
+
self.close()
|
|
65
|
+
|
|
66
|
+
def status(self) -> dict:
|
|
67
|
+
with self._lock:
|
|
68
|
+
buffered = len(self._buffer)
|
|
69
|
+
return {
|
|
70
|
+
"initialized": True,
|
|
71
|
+
"endpoint": self.endpoint,
|
|
72
|
+
"buffered_traces": buffered,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def _flush_loop(self):
|
|
76
|
+
while self._running:
|
|
77
|
+
time.sleep(self.flush_interval)
|
|
78
|
+
self.flush()
|
|
79
|
+
|
|
80
|
+
def _send_batch(self):
|
|
81
|
+
"""Send buffered traces. Must be called with self._lock held."""
|
|
82
|
+
if not self._buffer:
|
|
83
|
+
return
|
|
84
|
+
batch = self._buffer[:self.batch_size]
|
|
85
|
+
self._buffer = self._buffer[self.batch_size:]
|
|
86
|
+
try:
|
|
87
|
+
self._http.post("/v1/traces/batch", json={"traces": batch})
|
|
88
|
+
except Exception:
|
|
89
|
+
# Re-add to buffer on failure (best effort)
|
|
90
|
+
self._buffer = batch + self._buffer
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import time
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from agentwatchx.schemas import TraceData
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def trace(
|
|
9
|
+
func: Callable | None = None,
|
|
10
|
+
*,
|
|
11
|
+
service: str = "default",
|
|
12
|
+
llm_model: str | None = None,
|
|
13
|
+
):
|
|
14
|
+
"""
|
|
15
|
+
Decorator to trace a function execution.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
@agentwatchx.trace
|
|
19
|
+
def my_agent(input_text):
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
@agentwatchx.trace(service="order-agent", llm_model="gpt-4")
|
|
23
|
+
def my_agent(input_text):
|
|
24
|
+
...
|
|
25
|
+
"""
|
|
26
|
+
def decorator(fn: Callable) -> Callable:
|
|
27
|
+
@functools.wraps(fn)
|
|
28
|
+
def wrapper(*args, **kwargs):
|
|
29
|
+
import agentwatchx
|
|
30
|
+
|
|
31
|
+
input_text = str(args[0]) if args else str(kwargs) if kwargs else None
|
|
32
|
+
start = time.perf_counter()
|
|
33
|
+
error = None
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
result = fn(*args, **kwargs)
|
|
37
|
+
return result
|
|
38
|
+
except Exception as e:
|
|
39
|
+
error = e
|
|
40
|
+
raise
|
|
41
|
+
finally:
|
|
42
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
43
|
+
output_text = None if error else str(result) if 'result' in dir() else None
|
|
44
|
+
|
|
45
|
+
trace_data = TraceData(
|
|
46
|
+
service=service,
|
|
47
|
+
input_text=input_text,
|
|
48
|
+
output_text=output_text,
|
|
49
|
+
llm_model=llm_model,
|
|
50
|
+
duration_ms=round(duration_ms, 2),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
client = agentwatchx.get_client()
|
|
55
|
+
client.capture(trace_data)
|
|
56
|
+
except RuntimeError:
|
|
57
|
+
pass # SDK not initialized, silently skip
|
|
58
|
+
|
|
59
|
+
return wrapper
|
|
60
|
+
|
|
61
|
+
if func is not None:
|
|
62
|
+
return decorator(func)
|
|
63
|
+
return decorator
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-instrumentation registry.
|
|
3
|
+
|
|
4
|
+
Detects installed LLM libraries and patches them to capture traces
|
|
5
|
+
automatically after agentwatchx.init() is called.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("agentwatchx.integrations")
|
|
12
|
+
|
|
13
|
+
_PATCHES = {
|
|
14
|
+
"openai": "agentwatchx.integrations.openai_patch",
|
|
15
|
+
"anthropic": "agentwatchx.integrations.anthropic_patch",
|
|
16
|
+
"langchain_core": "agentwatchx.integrations.langchain_cb",
|
|
17
|
+
"llama_index": "agentwatchx.integrations.llamaindex_cb",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_applied: list[str] = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def auto_instrument(client):
|
|
24
|
+
"""Detect installed libraries and apply patches. Called by init()."""
|
|
25
|
+
for lib_name, patch_module in _PATCHES.items():
|
|
26
|
+
if lib_name in _applied:
|
|
27
|
+
continue # already patched, skip
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
importlib.import_module(lib_name)
|
|
31
|
+
except ImportError:
|
|
32
|
+
continue # library not installed, skip
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
mod = importlib.import_module(patch_module)
|
|
36
|
+
mod.patch(client)
|
|
37
|
+
_applied.append(lib_name)
|
|
38
|
+
logger.info("Instrumented %s", lib_name)
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
logger.warning("Failed to instrument %s: %s", lib_name, exc)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_instrumented() -> list[str]:
|
|
44
|
+
"""Return list of libraries that were successfully instrumented."""
|
|
45
|
+
return list(_applied)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def unpatch_all():
|
|
49
|
+
"""Remove all patches (for testing / shutdown)."""
|
|
50
|
+
for lib_name, patch_module in _PATCHES.items():
|
|
51
|
+
if lib_name not in _applied:
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
mod = importlib.import_module(patch_module)
|
|
55
|
+
if hasattr(mod, "unpatch"):
|
|
56
|
+
mod.unpatch()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
_applied.clear()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-instrumentation for the Anthropic Python SDK.
|
|
3
|
+
|
|
4
|
+
Patches:
|
|
5
|
+
- messages.create (sync + async)
|
|
6
|
+
- Handles both regular and streaming responses
|
|
7
|
+
|
|
8
|
+
After agentwatchx.init(), every Anthropic call is traced automatically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import functools
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("agentwatchx.integrations.anthropic")
|
|
16
|
+
|
|
17
|
+
_original_create = None
|
|
18
|
+
_original_async_create = None
|
|
19
|
+
_client_ref = None
|
|
20
|
+
_patched = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def patch(awx_client):
|
|
24
|
+
"""Monkey-patch anthropic messages.create."""
|
|
25
|
+
global _original_create, _original_async_create, _client_ref, _patched
|
|
26
|
+
if _patched:
|
|
27
|
+
return
|
|
28
|
+
_client_ref = awx_client
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from anthropic.resources.messages import Messages
|
|
32
|
+
except ImportError:
|
|
33
|
+
logger.debug("anthropic messages module not found, skipping")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
_original_create = Messages.create
|
|
37
|
+
|
|
38
|
+
@functools.wraps(_original_create)
|
|
39
|
+
def patched_create(self, *args, **kwargs):
|
|
40
|
+
stream = kwargs.get("stream", False)
|
|
41
|
+
start = time.perf_counter()
|
|
42
|
+
|
|
43
|
+
if stream:
|
|
44
|
+
result = _original_create(self, *args, **kwargs)
|
|
45
|
+
return _wrap_stream(result, kwargs, start)
|
|
46
|
+
|
|
47
|
+
error = None
|
|
48
|
+
result = None
|
|
49
|
+
try:
|
|
50
|
+
result = _original_create(self, *args, **kwargs)
|
|
51
|
+
return result
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
error = exc
|
|
54
|
+
raise
|
|
55
|
+
finally:
|
|
56
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
57
|
+
_capture_trace(kwargs, result, error, duration_ms)
|
|
58
|
+
|
|
59
|
+
Messages.create = patched_create
|
|
60
|
+
|
|
61
|
+
# Patch async
|
|
62
|
+
try:
|
|
63
|
+
from anthropic.resources.messages import AsyncMessages
|
|
64
|
+
_original_async_create = AsyncMessages.create
|
|
65
|
+
|
|
66
|
+
@functools.wraps(_original_async_create)
|
|
67
|
+
async def patched_async_create(self, *args, **kwargs):
|
|
68
|
+
stream = kwargs.get("stream", False)
|
|
69
|
+
start = time.perf_counter()
|
|
70
|
+
|
|
71
|
+
if stream:
|
|
72
|
+
result = await _original_async_create(self, *args, **kwargs)
|
|
73
|
+
return _wrap_async_stream(result, kwargs, start)
|
|
74
|
+
|
|
75
|
+
error = None
|
|
76
|
+
result = None
|
|
77
|
+
try:
|
|
78
|
+
result = await _original_async_create(self, *args, **kwargs)
|
|
79
|
+
return result
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
error = exc
|
|
82
|
+
raise
|
|
83
|
+
finally:
|
|
84
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
85
|
+
_capture_trace(kwargs, result, error, duration_ms)
|
|
86
|
+
|
|
87
|
+
AsyncMessages.create = patched_async_create
|
|
88
|
+
except ImportError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
_patched = True
|
|
92
|
+
logger.info("Anthropic auto-instrumentation applied")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unpatch():
|
|
96
|
+
"""Restore original methods."""
|
|
97
|
+
global _original_create, _original_async_create, _patched
|
|
98
|
+
if _original_create:
|
|
99
|
+
from anthropic.resources.messages import Messages
|
|
100
|
+
Messages.create = _original_create
|
|
101
|
+
_original_create = None
|
|
102
|
+
if _original_async_create:
|
|
103
|
+
from anthropic.resources.messages import AsyncMessages
|
|
104
|
+
AsyncMessages.create = _original_async_create
|
|
105
|
+
_original_async_create = None
|
|
106
|
+
_patched = False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _wrap_stream(stream, kwargs, start):
|
|
110
|
+
"""Wrap a sync Anthropic stream to capture events."""
|
|
111
|
+
content_parts = []
|
|
112
|
+
input_tokens = 0
|
|
113
|
+
output_tokens = 0
|
|
114
|
+
try:
|
|
115
|
+
for event in stream:
|
|
116
|
+
# Collect text deltas
|
|
117
|
+
if hasattr(event, "type"):
|
|
118
|
+
if event.type == "content_block_delta" and hasattr(event, "delta"):
|
|
119
|
+
if hasattr(event.delta, "text"):
|
|
120
|
+
content_parts.append(event.delta.text)
|
|
121
|
+
elif event.type == "message_delta" and hasattr(event, "usage"):
|
|
122
|
+
output_tokens = getattr(event.usage, "output_tokens", 0)
|
|
123
|
+
elif event.type == "message_start" and hasattr(event, "message"):
|
|
124
|
+
usage = getattr(event.message, "usage", None)
|
|
125
|
+
if usage:
|
|
126
|
+
input_tokens = getattr(usage, "input_tokens", 0)
|
|
127
|
+
yield event
|
|
128
|
+
finally:
|
|
129
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
130
|
+
_capture_stream_trace(kwargs, content_parts, input_tokens, output_tokens, duration_ms)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _wrap_async_stream(stream, kwargs, start):
|
|
134
|
+
"""Wrap an async Anthropic stream."""
|
|
135
|
+
content_parts = []
|
|
136
|
+
input_tokens = 0
|
|
137
|
+
output_tokens = 0
|
|
138
|
+
try:
|
|
139
|
+
async for event in stream:
|
|
140
|
+
if hasattr(event, "type"):
|
|
141
|
+
if event.type == "content_block_delta" and hasattr(event, "delta"):
|
|
142
|
+
if hasattr(event.delta, "text"):
|
|
143
|
+
content_parts.append(event.delta.text)
|
|
144
|
+
elif event.type == "message_delta" and hasattr(event, "usage"):
|
|
145
|
+
output_tokens = getattr(event.usage, "output_tokens", 0)
|
|
146
|
+
elif event.type == "message_start" and hasattr(event, "message"):
|
|
147
|
+
usage = getattr(event.message, "usage", None)
|
|
148
|
+
if usage:
|
|
149
|
+
input_tokens = getattr(usage, "input_tokens", 0)
|
|
150
|
+
yield event
|
|
151
|
+
finally:
|
|
152
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
153
|
+
_capture_stream_trace(kwargs, content_parts, input_tokens, output_tokens, duration_ms)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _capture_stream_trace(kwargs, content_parts, input_tokens, output_tokens, duration_ms):
|
|
157
|
+
"""Build trace from collected stream data."""
|
|
158
|
+
if _client_ref is None:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
from agentwatchx.schemas import TraceData
|
|
162
|
+
|
|
163
|
+
model = kwargs.get("model", "unknown")
|
|
164
|
+
messages = kwargs.get("messages", [])
|
|
165
|
+
input_text = _extract_input(messages)
|
|
166
|
+
output_text = "".join(content_parts)
|
|
167
|
+
|
|
168
|
+
metadata = {"provider": "anthropic", "streamed": True}
|
|
169
|
+
if input_tokens or output_tokens:
|
|
170
|
+
metadata["input_tokens"] = input_tokens
|
|
171
|
+
metadata["output_tokens"] = output_tokens
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
_client_ref.capture(TraceData(
|
|
175
|
+
service="anthropic",
|
|
176
|
+
input_text=input_text,
|
|
177
|
+
output_text=output_text or None,
|
|
178
|
+
llm_model=model,
|
|
179
|
+
duration_ms=round(duration_ms, 2),
|
|
180
|
+
metadata=metadata,
|
|
181
|
+
))
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
logger.debug("Failed to capture Anthropic stream trace: %s", exc)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _extract_input(messages):
|
|
187
|
+
"""Pull input text from the last message."""
|
|
188
|
+
if not messages:
|
|
189
|
+
return ""
|
|
190
|
+
last = messages[-1]
|
|
191
|
+
if isinstance(last, dict):
|
|
192
|
+
content = last.get("content", "")
|
|
193
|
+
return content if isinstance(content, str) else str(content)
|
|
194
|
+
return str(last)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _capture_trace(kwargs, result, error, duration_ms):
|
|
198
|
+
"""Extract trace data from a non-streaming Anthropic call."""
|
|
199
|
+
if _client_ref is None:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
from agentwatchx.schemas import TraceData
|
|
203
|
+
|
|
204
|
+
model = kwargs.get("model", "unknown")
|
|
205
|
+
messages = kwargs.get("messages", [])
|
|
206
|
+
input_text = _extract_input(messages)
|
|
207
|
+
|
|
208
|
+
output_text = None
|
|
209
|
+
tool_calls = []
|
|
210
|
+
metadata = {"provider": "anthropic"}
|
|
211
|
+
|
|
212
|
+
if error:
|
|
213
|
+
metadata["error"] = str(error)
|
|
214
|
+
elif result:
|
|
215
|
+
try:
|
|
216
|
+
blocks = result.content
|
|
217
|
+
text_parts = []
|
|
218
|
+
for b in blocks:
|
|
219
|
+
if hasattr(b, "text"):
|
|
220
|
+
text_parts.append(b.text)
|
|
221
|
+
elif hasattr(b, "type") and b.type == "tool_use":
|
|
222
|
+
from agentwatchx.schemas import ToolCallData
|
|
223
|
+
tool_calls.append(ToolCallData(
|
|
224
|
+
name=b.name,
|
|
225
|
+
input=b.input if hasattr(b, "input") else None,
|
|
226
|
+
status="success",
|
|
227
|
+
))
|
|
228
|
+
output_text = " ".join(text_parts)
|
|
229
|
+
|
|
230
|
+
if hasattr(result, "usage") and result.usage:
|
|
231
|
+
metadata["input_tokens"] = result.usage.input_tokens
|
|
232
|
+
metadata["output_tokens"] = result.usage.output_tokens
|
|
233
|
+
except (AttributeError, TypeError):
|
|
234
|
+
output_text = str(result)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
_client_ref.capture(TraceData(
|
|
238
|
+
service="anthropic",
|
|
239
|
+
input_text=input_text,
|
|
240
|
+
output_text=output_text,
|
|
241
|
+
llm_model=model,
|
|
242
|
+
duration_ms=round(duration_ms, 2),
|
|
243
|
+
tool_calls=tool_calls,
|
|
244
|
+
metadata=metadata,
|
|
245
|
+
))
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
logger.debug("Failed to capture Anthropic trace: %s", exc)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-instrumentation for LangChain via callback handler.
|
|
3
|
+
|
|
4
|
+
Registers an AgentWatchX callback handler globally so all LangChain
|
|
5
|
+
LLM/chain/chat model invocations are traced automatically.
|
|
6
|
+
|
|
7
|
+
Works with langchain-core >= 0.1.0 (the modern package structure).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("agentwatchx.integrations.langchain")
|
|
16
|
+
|
|
17
|
+
_client_ref = None
|
|
18
|
+
_handler_instance = None
|
|
19
|
+
_patched = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def patch(awx_client):
|
|
23
|
+
"""Create and globally register a LangChain callback handler."""
|
|
24
|
+
global _client_ref, _handler_instance, _patched
|
|
25
|
+
if _patched:
|
|
26
|
+
return
|
|
27
|
+
_client_ref = awx_client
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
31
|
+
except ImportError:
|
|
32
|
+
logger.debug("langchain_core not available, skipping")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
class AWXCallbackHandler(BaseCallbackHandler):
|
|
36
|
+
"""Captures LLM start/end events and sends them as traces."""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self._runs: dict[UUID, dict] = {}
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def always_verbose(self) -> bool:
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def on_llm_start(
|
|
46
|
+
self,
|
|
47
|
+
serialized: dict[str, Any],
|
|
48
|
+
prompts: list[str],
|
|
49
|
+
*,
|
|
50
|
+
run_id: UUID,
|
|
51
|
+
**kwargs: Any,
|
|
52
|
+
) -> None:
|
|
53
|
+
model = (
|
|
54
|
+
kwargs.get("invocation_params", {}).get("model_name")
|
|
55
|
+
or kwargs.get("invocation_params", {}).get("model")
|
|
56
|
+
or serialized.get("kwargs", {}).get("model_name")
|
|
57
|
+
or serialized.get("kwargs", {}).get("model")
|
|
58
|
+
or "unknown"
|
|
59
|
+
)
|
|
60
|
+
self._runs[run_id] = {
|
|
61
|
+
"start": time.perf_counter(),
|
|
62
|
+
"input_text": prompts[0] if prompts else "",
|
|
63
|
+
"model": model,
|
|
64
|
+
"metadata": {"provider": "langchain"},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def on_chat_model_start(
|
|
68
|
+
self,
|
|
69
|
+
serialized: dict[str, Any],
|
|
70
|
+
messages: list[list],
|
|
71
|
+
*,
|
|
72
|
+
run_id: UUID,
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> None:
|
|
75
|
+
input_text = ""
|
|
76
|
+
if messages and messages[0]:
|
|
77
|
+
last_msg = messages[0][-1]
|
|
78
|
+
if hasattr(last_msg, "content"):
|
|
79
|
+
input_text = str(last_msg.content)
|
|
80
|
+
else:
|
|
81
|
+
input_text = str(last_msg)
|
|
82
|
+
|
|
83
|
+
model = (
|
|
84
|
+
kwargs.get("invocation_params", {}).get("model_name")
|
|
85
|
+
or kwargs.get("invocation_params", {}).get("model")
|
|
86
|
+
or serialized.get("kwargs", {}).get("model_name")
|
|
87
|
+
or serialized.get("kwargs", {}).get("model")
|
|
88
|
+
or "unknown"
|
|
89
|
+
)
|
|
90
|
+
self._runs[run_id] = {
|
|
91
|
+
"start": time.perf_counter(),
|
|
92
|
+
"input_text": input_text,
|
|
93
|
+
"model": model,
|
|
94
|
+
"metadata": {"provider": "langchain"},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def on_llm_end(self, response, *, run_id: UUID, **kwargs: Any) -> None:
|
|
98
|
+
run = self._runs.pop(run_id, None)
|
|
99
|
+
if not run or _client_ref is None:
|
|
100
|
+
return
|
|
101
|
+
duration_ms = (time.perf_counter() - run["start"]) * 1000
|
|
102
|
+
|
|
103
|
+
output_text = ""
|
|
104
|
+
try:
|
|
105
|
+
gen = response.generations[0][0]
|
|
106
|
+
output_text = gen.text if hasattr(gen, "text") else str(gen)
|
|
107
|
+
# Capture token usage if available
|
|
108
|
+
if hasattr(response, "llm_output") and response.llm_output:
|
|
109
|
+
usage = response.llm_output.get("token_usage", {})
|
|
110
|
+
if usage:
|
|
111
|
+
run["metadata"]["tokens"] = usage
|
|
112
|
+
except (IndexError, AttributeError):
|
|
113
|
+
output_text = str(response)
|
|
114
|
+
|
|
115
|
+
from agentwatchx.schemas import TraceData
|
|
116
|
+
try:
|
|
117
|
+
_client_ref.capture(TraceData(
|
|
118
|
+
service="langchain",
|
|
119
|
+
input_text=run["input_text"],
|
|
120
|
+
output_text=output_text,
|
|
121
|
+
llm_model=run["model"],
|
|
122
|
+
duration_ms=round(duration_ms, 2),
|
|
123
|
+
metadata=run["metadata"],
|
|
124
|
+
))
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
logger.debug("Failed to capture LangChain trace: %s", exc)
|
|
127
|
+
|
|
128
|
+
def on_llm_error(self, error, *, run_id: UUID, **kwargs: Any) -> None:
|
|
129
|
+
run = self._runs.pop(run_id, None)
|
|
130
|
+
if not run or _client_ref is None:
|
|
131
|
+
return
|
|
132
|
+
duration_ms = (time.perf_counter() - run["start"]) * 1000
|
|
133
|
+
run["metadata"]["error"] = str(error)
|
|
134
|
+
|
|
135
|
+
from agentwatchx.schemas import TraceData
|
|
136
|
+
try:
|
|
137
|
+
_client_ref.capture(TraceData(
|
|
138
|
+
service="langchain",
|
|
139
|
+
input_text=run["input_text"],
|
|
140
|
+
output_text=None,
|
|
141
|
+
llm_model=run["model"],
|
|
142
|
+
duration_ms=round(duration_ms, 2),
|
|
143
|
+
metadata=run["metadata"],
|
|
144
|
+
))
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.debug("Failed to capture LangChain error trace: %s", exc)
|
|
147
|
+
|
|
148
|
+
_handler_instance = AWXCallbackHandler()
|
|
149
|
+
|
|
150
|
+
# Register globally by patching the callback manager's configure function.
|
|
151
|
+
# This ensures our handler is included in every LLM/chain invocation
|
|
152
|
+
# without requiring users to pass callbacks= manually.
|
|
153
|
+
try:
|
|
154
|
+
from langchain_core.callbacks.manager import CallbackManager
|
|
155
|
+
|
|
156
|
+
_original_configure = CallbackManager.configure
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def patched_configure(cls, *args, **kwargs):
|
|
160
|
+
manager = _original_configure.__func__(cls, *args, **kwargs)
|
|
161
|
+
if manager is not None and _handler_instance is not None:
|
|
162
|
+
# Avoid duplicates
|
|
163
|
+
handler_types = {type(h) for h in manager.handlers}
|
|
164
|
+
if type(_handler_instance) not in handler_types:
|
|
165
|
+
manager.add_handler(_handler_instance)
|
|
166
|
+
return manager
|
|
167
|
+
|
|
168
|
+
CallbackManager.configure = patched_configure
|
|
169
|
+
logger.info("LangChain global callback handler registered via CallbackManager.configure")
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
logger.warning("Could not register LangChain handler globally: %s. "
|
|
172
|
+
"Use get_callback_handler() manually.", exc)
|
|
173
|
+
|
|
174
|
+
_patched = True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_callback_handler():
|
|
178
|
+
"""Return the AWX callback handler for manual use.
|
|
179
|
+
|
|
180
|
+
Usage with LangChain:
|
|
181
|
+
from agentwatchx.integrations.langchain_cb import get_callback_handler
|
|
182
|
+
llm.invoke("prompt", config={"callbacks": [get_callback_handler()]})
|
|
183
|
+
"""
|
|
184
|
+
return _handler_instance
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def unpatch():
|
|
188
|
+
"""Remove the global callback handler."""
|
|
189
|
+
global _handler_instance, _patched
|
|
190
|
+
_handler_instance = None
|
|
191
|
+
_patched = False
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-instrumentation for LlamaIndex via its callback system.
|
|
3
|
+
|
|
4
|
+
Registers an AgentWatchX handler that captures LLM call events automatically.
|
|
5
|
+
Supports both llama-index >= 0.10 (llama_index.core) and legacy paths.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("agentwatchx.integrations.llamaindex")
|
|
13
|
+
|
|
14
|
+
_client_ref = None
|
|
15
|
+
_handler_instance = None
|
|
16
|
+
_patched = False
|
|
17
|
+
|
|
18
|
+
# Resolved at patch time
|
|
19
|
+
_CBEventType = None
|
|
20
|
+
_CallbackManager = None
|
|
21
|
+
_BaseCallbackHandler = None
|
|
22
|
+
_Settings = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def patch(awx_client):
|
|
26
|
+
"""Register a LlamaIndex callback handler globally."""
|
|
27
|
+
global _client_ref, _handler_instance, _patched
|
|
28
|
+
global _CBEventType, _CallbackManager, _BaseCallbackHandler, _Settings
|
|
29
|
+
|
|
30
|
+
if _patched:
|
|
31
|
+
return
|
|
32
|
+
_client_ref = awx_client
|
|
33
|
+
|
|
34
|
+
# Try modern import path first (llama-index >= 0.10)
|
|
35
|
+
try:
|
|
36
|
+
from llama_index.core.callbacks import CallbackManager, CBEventType
|
|
37
|
+
from llama_index.core.callbacks.base_handler import BaseCallbackHandler
|
|
38
|
+
from llama_index.core import Settings
|
|
39
|
+
except ImportError:
|
|
40
|
+
# Legacy import path
|
|
41
|
+
try:
|
|
42
|
+
from llama_index.callbacks import CallbackManager, CBEventType
|
|
43
|
+
from llama_index.callbacks.base_handler import BaseCallbackHandler
|
|
44
|
+
from llama_index import Settings
|
|
45
|
+
except ImportError:
|
|
46
|
+
logger.debug("llama_index callback modules not found, skipping")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
_CBEventType = CBEventType
|
|
50
|
+
_CallbackManager = CallbackManager
|
|
51
|
+
_BaseCallbackHandler = BaseCallbackHandler
|
|
52
|
+
_Settings = Settings
|
|
53
|
+
|
|
54
|
+
class AWXLlamaHandler(BaseCallbackHandler):
|
|
55
|
+
"""Captures LlamaIndex LLM events and sends them as traces."""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
super().__init__(
|
|
59
|
+
event_starts_to_trace=[CBEventType.LLM],
|
|
60
|
+
event_ends_to_trace=[CBEventType.LLM],
|
|
61
|
+
)
|
|
62
|
+
self._events: dict[str, dict] = {}
|
|
63
|
+
|
|
64
|
+
def on_event_start(
|
|
65
|
+
self,
|
|
66
|
+
event_type: CBEventType,
|
|
67
|
+
payload: dict | None = None,
|
|
68
|
+
event_id: str = "",
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> str:
|
|
71
|
+
if event_type == CBEventType.LLM:
|
|
72
|
+
input_text = ""
|
|
73
|
+
model = "unknown"
|
|
74
|
+
if payload:
|
|
75
|
+
# Try to extract from messages
|
|
76
|
+
messages = payload.get("messages", [])
|
|
77
|
+
if messages:
|
|
78
|
+
last = messages[-1]
|
|
79
|
+
input_text = str(last.content) if hasattr(last, "content") else str(last)
|
|
80
|
+
elif "prompt" in payload:
|
|
81
|
+
input_text = str(payload["prompt"])
|
|
82
|
+
|
|
83
|
+
model = (
|
|
84
|
+
payload.get("model_name")
|
|
85
|
+
or payload.get("model")
|
|
86
|
+
or "unknown"
|
|
87
|
+
)
|
|
88
|
+
self._events[event_id] = {
|
|
89
|
+
"start": time.perf_counter(),
|
|
90
|
+
"input_text": input_text,
|
|
91
|
+
"model": model,
|
|
92
|
+
"metadata": {"provider": "llamaindex"},
|
|
93
|
+
}
|
|
94
|
+
return event_id
|
|
95
|
+
|
|
96
|
+
def on_event_end(
|
|
97
|
+
self,
|
|
98
|
+
event_type: CBEventType,
|
|
99
|
+
payload: dict | None = None,
|
|
100
|
+
event_id: str = "",
|
|
101
|
+
**kwargs: Any,
|
|
102
|
+
) -> None:
|
|
103
|
+
if event_type != CBEventType.LLM:
|
|
104
|
+
return
|
|
105
|
+
ev = self._events.pop(event_id, None)
|
|
106
|
+
if not ev or _client_ref is None:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
duration_ms = (time.perf_counter() - ev["start"]) * 1000
|
|
110
|
+
output_text = ""
|
|
111
|
+
if payload:
|
|
112
|
+
response = payload.get("response", payload.get("completion", ""))
|
|
113
|
+
output_text = str(response)
|
|
114
|
+
# Token usage
|
|
115
|
+
tokens = payload.get("token_usage") or payload.get("usage")
|
|
116
|
+
if tokens:
|
|
117
|
+
ev["metadata"]["tokens"] = (
|
|
118
|
+
tokens if isinstance(tokens, dict) else str(tokens)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
from agentwatchx.schemas import TraceData
|
|
122
|
+
try:
|
|
123
|
+
_client_ref.capture(TraceData(
|
|
124
|
+
service="llamaindex",
|
|
125
|
+
input_text=ev["input_text"],
|
|
126
|
+
output_text=output_text or None,
|
|
127
|
+
llm_model=ev["model"],
|
|
128
|
+
duration_ms=round(duration_ms, 2),
|
|
129
|
+
metadata=ev["metadata"],
|
|
130
|
+
))
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
logger.debug("Failed to capture LlamaIndex trace: %s", exc)
|
|
133
|
+
|
|
134
|
+
def start_trace(self, trace_id: str | None = None) -> None:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
def end_trace(
|
|
138
|
+
self,
|
|
139
|
+
trace_id: str | None = None,
|
|
140
|
+
trace_map: dict[str, list[str]] | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
_handler_instance = AWXLlamaHandler()
|
|
145
|
+
|
|
146
|
+
# Register globally via Settings.callback_manager
|
|
147
|
+
try:
|
|
148
|
+
if Settings.callback_manager is None:
|
|
149
|
+
Settings.callback_manager = CallbackManager([_handler_instance])
|
|
150
|
+
else:
|
|
151
|
+
Settings.callback_manager.add_handler(_handler_instance)
|
|
152
|
+
logger.info("LlamaIndex callback handler registered globally")
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.warning("Could not register LlamaIndex handler globally: %s. "
|
|
155
|
+
"Use get_callback_handler() manually.", exc)
|
|
156
|
+
|
|
157
|
+
_patched = True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_callback_handler():
|
|
161
|
+
"""Return the AWX handler for manual use with LlamaIndex.
|
|
162
|
+
|
|
163
|
+
Usage:
|
|
164
|
+
from agentwatchx.integrations.llamaindex_cb import get_callback_handler
|
|
165
|
+
from llama_index.core import Settings
|
|
166
|
+
Settings.callback_manager.add_handler(get_callback_handler())
|
|
167
|
+
"""
|
|
168
|
+
return _handler_instance
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def unpatch():
|
|
172
|
+
"""Remove the handler."""
|
|
173
|
+
global _handler_instance, _patched
|
|
174
|
+
_handler_instance = None
|
|
175
|
+
_patched = False
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-instrumentation for the OpenAI Python SDK (v1+).
|
|
3
|
+
|
|
4
|
+
Patches:
|
|
5
|
+
- chat.completions.create (sync + async)
|
|
6
|
+
- Handles both regular and streaming responses
|
|
7
|
+
|
|
8
|
+
After agentwatchx.init(), every OpenAI call is traced automatically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
import functools
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("agentwatchx.integrations.openai")
|
|
16
|
+
|
|
17
|
+
_original_create = None
|
|
18
|
+
_original_async_create = None
|
|
19
|
+
_client_ref = None
|
|
20
|
+
_patched = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def patch(awx_client):
|
|
24
|
+
"""Monkey-patch openai chat completions."""
|
|
25
|
+
global _original_create, _original_async_create, _client_ref, _patched
|
|
26
|
+
if _patched:
|
|
27
|
+
return
|
|
28
|
+
_client_ref = awx_client
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from openai.resources.chat.completions import Completions
|
|
32
|
+
except ImportError:
|
|
33
|
+
logger.debug("openai chat completions module not found, skipping")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
_original_create = Completions.create
|
|
37
|
+
|
|
38
|
+
@functools.wraps(_original_create)
|
|
39
|
+
def patched_create(self, *args, **kwargs):
|
|
40
|
+
stream = kwargs.get("stream", False)
|
|
41
|
+
start = time.perf_counter()
|
|
42
|
+
|
|
43
|
+
if stream:
|
|
44
|
+
# For streaming, wrap the iterator to capture chunks
|
|
45
|
+
result = _original_create(self, *args, **kwargs)
|
|
46
|
+
return _wrap_stream(result, kwargs, start)
|
|
47
|
+
|
|
48
|
+
error = None
|
|
49
|
+
result = None
|
|
50
|
+
try:
|
|
51
|
+
result = _original_create(self, *args, **kwargs)
|
|
52
|
+
return result
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
error = exc
|
|
55
|
+
raise
|
|
56
|
+
finally:
|
|
57
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
58
|
+
_capture_trace(kwargs, result, error, duration_ms)
|
|
59
|
+
|
|
60
|
+
Completions.create = patched_create
|
|
61
|
+
|
|
62
|
+
# Patch async
|
|
63
|
+
try:
|
|
64
|
+
from openai.resources.chat.completions import AsyncCompletions
|
|
65
|
+
_original_async_create = AsyncCompletions.create
|
|
66
|
+
|
|
67
|
+
@functools.wraps(_original_async_create)
|
|
68
|
+
async def patched_async_create(self, *args, **kwargs):
|
|
69
|
+
stream = kwargs.get("stream", False)
|
|
70
|
+
start = time.perf_counter()
|
|
71
|
+
|
|
72
|
+
if stream:
|
|
73
|
+
result = await _original_async_create(self, *args, **kwargs)
|
|
74
|
+
return _wrap_async_stream(result, kwargs, start)
|
|
75
|
+
|
|
76
|
+
error = None
|
|
77
|
+
result = None
|
|
78
|
+
try:
|
|
79
|
+
result = await _original_async_create(self, *args, **kwargs)
|
|
80
|
+
return result
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
error = exc
|
|
83
|
+
raise
|
|
84
|
+
finally:
|
|
85
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
86
|
+
_capture_trace(kwargs, result, error, duration_ms)
|
|
87
|
+
|
|
88
|
+
AsyncCompletions.create = patched_async_create
|
|
89
|
+
except ImportError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
_patched = True
|
|
93
|
+
logger.info("OpenAI auto-instrumentation applied")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def unpatch():
|
|
97
|
+
"""Restore original methods."""
|
|
98
|
+
global _original_create, _original_async_create, _patched
|
|
99
|
+
if _original_create:
|
|
100
|
+
from openai.resources.chat.completions import Completions
|
|
101
|
+
Completions.create = _original_create
|
|
102
|
+
_original_create = None
|
|
103
|
+
if _original_async_create:
|
|
104
|
+
from openai.resources.chat.completions import AsyncCompletions
|
|
105
|
+
AsyncCompletions.create = _original_async_create
|
|
106
|
+
_original_async_create = None
|
|
107
|
+
_patched = False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _wrap_stream(stream, kwargs, start):
|
|
111
|
+
"""Wrap a sync streaming response to capture the full output."""
|
|
112
|
+
chunks = []
|
|
113
|
+
try:
|
|
114
|
+
for chunk in stream:
|
|
115
|
+
chunks.append(chunk)
|
|
116
|
+
yield chunk
|
|
117
|
+
finally:
|
|
118
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
119
|
+
_capture_stream_trace(kwargs, chunks, duration_ms)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _wrap_async_stream(stream, kwargs, start):
|
|
123
|
+
"""Wrap an async streaming response to capture the full output."""
|
|
124
|
+
chunks = []
|
|
125
|
+
try:
|
|
126
|
+
async for chunk in stream:
|
|
127
|
+
chunks.append(chunk)
|
|
128
|
+
yield chunk
|
|
129
|
+
finally:
|
|
130
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
131
|
+
_capture_stream_trace(kwargs, chunks, duration_ms)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _capture_stream_trace(kwargs, chunks, duration_ms):
|
|
135
|
+
"""Build a trace from collected stream chunks."""
|
|
136
|
+
if _client_ref is None or not chunks:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
from agentwatchx.schemas import TraceData
|
|
140
|
+
|
|
141
|
+
model = kwargs.get("model", "unknown")
|
|
142
|
+
messages = kwargs.get("messages", [])
|
|
143
|
+
input_text = messages[-1].get("content", "") if messages else ""
|
|
144
|
+
|
|
145
|
+
# Reassemble streamed content
|
|
146
|
+
content_parts = []
|
|
147
|
+
for chunk in chunks:
|
|
148
|
+
try:
|
|
149
|
+
delta = chunk.choices[0].delta
|
|
150
|
+
if delta and delta.content:
|
|
151
|
+
content_parts.append(delta.content)
|
|
152
|
+
except (IndexError, AttributeError):
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
output_text = "".join(content_parts)
|
|
156
|
+
metadata = {"provider": "openai", "streamed": True}
|
|
157
|
+
|
|
158
|
+
# Try to get usage from the last chunk (OpenAI includes it there)
|
|
159
|
+
try:
|
|
160
|
+
last = chunks[-1]
|
|
161
|
+
if hasattr(last, "usage") and last.usage:
|
|
162
|
+
metadata["prompt_tokens"] = last.usage.prompt_tokens
|
|
163
|
+
metadata["completion_tokens"] = last.usage.completion_tokens
|
|
164
|
+
metadata["total_tokens"] = last.usage.total_tokens
|
|
165
|
+
except (AttributeError, TypeError):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
_client_ref.capture(TraceData(
|
|
170
|
+
service="openai",
|
|
171
|
+
input_text=input_text,
|
|
172
|
+
output_text=output_text or None,
|
|
173
|
+
llm_model=model,
|
|
174
|
+
duration_ms=round(duration_ms, 2),
|
|
175
|
+
metadata=metadata,
|
|
176
|
+
))
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
logger.debug("Failed to capture OpenAI stream trace: %s", exc)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _capture_trace(kwargs, result, error, duration_ms):
|
|
182
|
+
"""Extract trace data from a non-streaming OpenAI chat completion call."""
|
|
183
|
+
if _client_ref is None:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
from agentwatchx.schemas import TraceData
|
|
187
|
+
|
|
188
|
+
model = kwargs.get("model", "unknown")
|
|
189
|
+
messages = kwargs.get("messages", [])
|
|
190
|
+
input_text = messages[-1].get("content", "") if messages else ""
|
|
191
|
+
|
|
192
|
+
output_text = None
|
|
193
|
+
tool_calls = []
|
|
194
|
+
metadata = {"provider": "openai"}
|
|
195
|
+
|
|
196
|
+
if error:
|
|
197
|
+
metadata["error"] = str(error)
|
|
198
|
+
elif result:
|
|
199
|
+
try:
|
|
200
|
+
choice = result.choices[0]
|
|
201
|
+
output_text = choice.message.content or ""
|
|
202
|
+
|
|
203
|
+
# Capture tool calls if the model made any
|
|
204
|
+
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
|
|
205
|
+
from agentwatchx.schemas import ToolCallData
|
|
206
|
+
for tc in choice.message.tool_calls:
|
|
207
|
+
tool_calls.append(ToolCallData(
|
|
208
|
+
name=tc.function.name,
|
|
209
|
+
input=tc.function.arguments,
|
|
210
|
+
status="success",
|
|
211
|
+
))
|
|
212
|
+
|
|
213
|
+
if hasattr(result, "usage") and result.usage:
|
|
214
|
+
metadata["prompt_tokens"] = result.usage.prompt_tokens
|
|
215
|
+
metadata["completion_tokens"] = result.usage.completion_tokens
|
|
216
|
+
metadata["total_tokens"] = result.usage.total_tokens
|
|
217
|
+
except (IndexError, AttributeError):
|
|
218
|
+
output_text = str(result)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
_client_ref.capture(TraceData(
|
|
222
|
+
service="openai",
|
|
223
|
+
input_text=input_text,
|
|
224
|
+
output_text=output_text,
|
|
225
|
+
llm_model=model,
|
|
226
|
+
duration_ms=round(duration_ms, 2),
|
|
227
|
+
tool_calls=tool_calls,
|
|
228
|
+
metadata=metadata,
|
|
229
|
+
))
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
logger.debug("Failed to capture OpenAI trace: %s", exc)
|
agentwatchx/schemas.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ToolCallData(BaseModel):
|
|
5
|
+
name: str
|
|
6
|
+
input: dict | str | None = None
|
|
7
|
+
output: dict | str | None = None
|
|
8
|
+
status: str = "success"
|
|
9
|
+
duration_ms: float | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TraceData(BaseModel):
|
|
13
|
+
service: str = "default"
|
|
14
|
+
input_text: str | None = None
|
|
15
|
+
output_text: str | None = None
|
|
16
|
+
llm_model: str | None = None
|
|
17
|
+
duration_ms: float | None = None
|
|
18
|
+
tool_calls: list[ToolCallData] = []
|
|
19
|
+
metadata: dict = {}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentwatchx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Observability SDK for AI Agent Execution — catches hallucinations, silent failures, and missing executions
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://agentwatchx.com
|
|
7
|
+
Project-URL: Documentation, https://agentwatchx.com/docs
|
|
8
|
+
Keywords: ai,agents,observability,llm,tracing,openai,anthropic,langchain
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: httpx>=0.25.0
|
|
18
|
+
Requires-Dist: pydantic>=2.0.0
|
|
19
|
+
Provides-Extra: openai
|
|
20
|
+
Requires-Dist: openai>=1.0.0; extra == "openai"
|
|
21
|
+
Provides-Extra: anthropic
|
|
22
|
+
Requires-Dist: anthropic>=0.18.0; extra == "anthropic"
|
|
23
|
+
Provides-Extra: langchain
|
|
24
|
+
Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
|
|
25
|
+
Provides-Extra: llamaindex
|
|
26
|
+
Requires-Dist: llama-index-core>=0.10.0; extra == "llamaindex"
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: openai>=1.0.0; extra == "all"
|
|
29
|
+
Requires-Dist: anthropic>=0.18.0; extra == "all"
|
|
30
|
+
Requires-Dist: langchain-core>=0.1.0; extra == "all"
|
|
31
|
+
Requires-Dist: llama-index-core>=0.10.0; extra == "all"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# AgentWatchX Python SDK
|
|
35
|
+
|
|
36
|
+
Observability for AI agents. Catches hallucinations, silent failures, and missing executions automatically.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install agentwatchx
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 2-Line Integration
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import agentwatchx
|
|
48
|
+
agentwatchx.init(api_key="your_api_key")
|
|
49
|
+
|
|
50
|
+
# That's it. Every OpenAI/Anthropic/LangChain/LlamaIndex call is now traced.
|
|
51
|
+
# No decorators, no wrappers, no code changes needed.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## What it captures
|
|
55
|
+
|
|
56
|
+
- Model, input, output, token usage, latency — automatically
|
|
57
|
+
- Tool calls made by the LLM (function calling)
|
|
58
|
+
- Hallucination detection — agent claims actions it never performed
|
|
59
|
+
- Failure masking — tool errors the agent hides from users
|
|
60
|
+
- Missing execution — user asks for action, agent just talks
|
|
61
|
+
|
|
62
|
+
## Supported Libraries
|
|
63
|
+
|
|
64
|
+
| Library | Auto-instrumented |
|
|
65
|
+
|---|---|
|
|
66
|
+
| OpenAI | ✅ |
|
|
67
|
+
| Anthropic | ✅ |
|
|
68
|
+
| LangChain | ✅ |
|
|
69
|
+
| LlamaIndex | ✅ |
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
agentwatchx.init(
|
|
75
|
+
api_key="your_api_key",
|
|
76
|
+
endpoint="https://api.agentwatchx.com", # or self-hosted
|
|
77
|
+
service="my-agent",
|
|
78
|
+
flush_interval=5.0,
|
|
79
|
+
batch_size=100,
|
|
80
|
+
auto_instrument=True, # set False to disable auto-patching
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Advanced Usage
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# Manual trace decorator
|
|
88
|
+
@agentwatchx.trace
|
|
89
|
+
def my_agent(query: str):
|
|
90
|
+
return client.chat.completions.create(...)
|
|
91
|
+
|
|
92
|
+
# Manual logging
|
|
93
|
+
agentwatchx.log({"service": "my-agent", "input": "hello", "output": "world"})
|
|
94
|
+
|
|
95
|
+
# Check status
|
|
96
|
+
print(agentwatchx.status())
|
|
97
|
+
# → {"initialized": True, "instrumented": ["openai"], "buffered_traces": 0}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Links
|
|
101
|
+
|
|
102
|
+
- [Documentation](https://agentwatchx.com/docs)
|
|
103
|
+
- [Dashboard](https://agentwatchx.com/dashboard)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
agentwatchx/__init__.py,sha256=kFMrM1QDujlVE40h7eLyAwS1isIx8zKDR9qgaI9g_hI,3547
|
|
2
|
+
agentwatchx/client.py,sha256=Ne-LViVPL0bxTFxHcJ_PIa9Rw3MzT2nOXOywAKj_kP8,2643
|
|
3
|
+
agentwatchx/decorators.py,sha256=VfXJFhHNWqeIbhhcHAunf85DtTpbv0WP8YXhPrvoy38,1744
|
|
4
|
+
agentwatchx/schemas.py,sha256=Z2-iIw_KgzsmmLNDtt86wgOdrT-hARO3vfVo6AiTbvg,478
|
|
5
|
+
agentwatchx/integrations/__init__.py,sha256=TA-vaORvKhvDuw2H2NAdOjG8GD33dMb7PXFlsZb-FPk,1727
|
|
6
|
+
agentwatchx/integrations/anthropic_patch.py,sha256=t1CGK5cmfNhm77JJgc5RwpYH8DAc_XE0OKZ1tTn4yss,8328
|
|
7
|
+
agentwatchx/integrations/langchain_cb.py,sha256=RJJnYiB0_OJ5u2SfwAhUrUYx2kecniHQD-AUHuBJ-YE,6826
|
|
8
|
+
agentwatchx/integrations/llamaindex_cb.py,sha256=vIT0JqZVH6Ax2ZLBVhjJnN1OOiMMG_EzbVXyjmq2kX8,5915
|
|
9
|
+
agentwatchx/integrations/openai_patch.py,sha256=7vCAoUMz1YlESCvOPcSmLBbPd3keTr8QBmG5Jclk7lE,7408
|
|
10
|
+
agentwatchx-0.1.0.dist-info/licenses/LICENSE,sha256=ayCgDXVU9p_G32jQoiRdGKnzuvUVX9Nd6DOphbLT2lI,1068
|
|
11
|
+
agentwatchx-0.1.0.dist-info/METADATA,sha256=evvx63TTUIyG_G6u9rNSH_OVizUHOx3q6DQR0FbKqC8,2959
|
|
12
|
+
agentwatchx-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
agentwatchx-0.1.0.dist-info/top_level.txt,sha256=3viPcduYhcOK5h4rybvsYiZzjIzYTk4OLz8LEPlzS0Q,12
|
|
14
|
+
agentwatchx-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AgentWatchX
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agentwatchx
|