agentlens-py 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.
- agentlens/__init__.py +62 -0
- agentlens/client.py +137 -0
- agentlens/context.py +30 -0
- agentlens/decorators.py +208 -0
- agentlens/instruments/anthropic.py +359 -0
- agentlens/instruments/openai.py +367 -0
- agentlens/pricing.py +37 -0
- agentlens_py-0.1.0.dist-info/METADATA +11 -0
- agentlens_py-0.1.0.dist-info/RECORD +10 -0
- agentlens_py-0.1.0.dist-info/WHEEL +4 -0
agentlens/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""AgentLens Python SDK — zero-overhead observability for AI agents.
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import atexit
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from agentlens.client import AgentLensClient
|
|
11
|
+
from agentlens.decorators import span, trace
|
|
12
|
+
from agentlens.instruments.anthropic import instrument_anthropic
|
|
13
|
+
from agentlens.instruments.openai import instrument_openai
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"init",
|
|
17
|
+
"instrument_openai",
|
|
18
|
+
"instrument_anthropic",
|
|
19
|
+
"trace",
|
|
20
|
+
"span",
|
|
21
|
+
"flush",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_global_client: Optional[AgentLensClient] = None
|
|
25
|
+
logger = logging.getLogger("agentlens")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init(
|
|
29
|
+
api_key: str,
|
|
30
|
+
base_url: str = "http://localhost:8000",
|
|
31
|
+
debug: bool = False,
|
|
32
|
+
enabled: bool = True,
|
|
33
|
+
) -> AgentLensClient:
|
|
34
|
+
"""Initialize the global AgentLens client."""
|
|
35
|
+
global _global_client
|
|
36
|
+
|
|
37
|
+
if _global_client is not None:
|
|
38
|
+
_global_client.stop()
|
|
39
|
+
|
|
40
|
+
_global_client = AgentLensClient(
|
|
41
|
+
api_key=api_key,
|
|
42
|
+
base_url=base_url,
|
|
43
|
+
debug=debug,
|
|
44
|
+
enabled=enabled,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Register exit handler to flush queue on shutdown
|
|
48
|
+
if enabled:
|
|
49
|
+
atexit.register(flush)
|
|
50
|
+
|
|
51
|
+
return _global_client
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def flush(timeout: float = 5.0) -> None:
|
|
55
|
+
"""Flush all currently buffered spans in the global client."""
|
|
56
|
+
global _global_client
|
|
57
|
+
if _global_client is not None and _global_client.enabled:
|
|
58
|
+
try:
|
|
59
|
+
_global_client.flush(timeout=timeout)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
if _global_client.debug:
|
|
62
|
+
logger.debug(f"Failed to flush spans: {e}", exc_info=True)
|
agentlens/client.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""AgentLens SDK Client for background batch span ingestion.
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import atexit
|
|
7
|
+
import logging
|
|
8
|
+
import queue
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("agentlens")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AgentLensClient:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str,
|
|
22
|
+
base_url: str = "http://localhost:8000",
|
|
23
|
+
debug: bool = False,
|
|
24
|
+
enabled: bool = True,
|
|
25
|
+
):
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.base_url = base_url.rstrip("/")
|
|
28
|
+
self.debug = debug
|
|
29
|
+
self.enabled = enabled
|
|
30
|
+
|
|
31
|
+
self._queue: queue.Queue[Dict[str, Any]] = queue.Queue(maxsize=10000)
|
|
32
|
+
self._stop_event = threading.Event()
|
|
33
|
+
self._worker_thread: Optional[threading.Thread] = None
|
|
34
|
+
|
|
35
|
+
if self.enabled:
|
|
36
|
+
self.start()
|
|
37
|
+
|
|
38
|
+
def start(self):
|
|
39
|
+
if self._worker_thread is not None and self._worker_thread.is_alive():
|
|
40
|
+
return
|
|
41
|
+
self._stop_event.clear()
|
|
42
|
+
self._worker_thread = threading.Thread(target=self._worker, daemon=True)
|
|
43
|
+
self._worker_thread.start()
|
|
44
|
+
|
|
45
|
+
def enqueue(self, item: Dict[str, Any]):
|
|
46
|
+
if not self.enabled:
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
self._queue.put_nowait(item)
|
|
50
|
+
except queue.Full:
|
|
51
|
+
logger.warning("AgentLens queue full. Draining one item to make space.")
|
|
52
|
+
try:
|
|
53
|
+
self._queue.get_nowait()
|
|
54
|
+
self._queue.task_done()
|
|
55
|
+
except (queue.Empty, ValueError):
|
|
56
|
+
pass
|
|
57
|
+
try:
|
|
58
|
+
self._queue.put_nowait(item)
|
|
59
|
+
except queue.Full:
|
|
60
|
+
pass # Fail-safe
|
|
61
|
+
|
|
62
|
+
def _safe_enqueue(self, item: Dict[str, Any]):
|
|
63
|
+
try:
|
|
64
|
+
self.enqueue(item)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
if self.debug:
|
|
67
|
+
logger.debug(f"Failed to enqueue span: {e}", exc_info=True)
|
|
68
|
+
|
|
69
|
+
def _send_batch(self, batch: List[Dict[str, Any]]):
|
|
70
|
+
if not batch:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
url = f"{self.base_url}/v1/ingest"
|
|
74
|
+
headers = {
|
|
75
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
}
|
|
78
|
+
payload = {"spans": batch}
|
|
79
|
+
|
|
80
|
+
# Sync request since we are on background worker thread
|
|
81
|
+
with httpx.Client(timeout=10.0) as client:
|
|
82
|
+
resp = client.post(url, json=payload, headers=headers)
|
|
83
|
+
if resp.status_code != 200:
|
|
84
|
+
if self.debug:
|
|
85
|
+
logger.debug(
|
|
86
|
+
f"Ingest failed with status {resp.status_code}: {resp.text}"
|
|
87
|
+
)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
if self.debug:
|
|
90
|
+
logger.debug(f"Error sending batch to AgentLens: {e}", exc_info=True)
|
|
91
|
+
|
|
92
|
+
def _worker(self):
|
|
93
|
+
while not self._stop_event.is_set():
|
|
94
|
+
batch = []
|
|
95
|
+
start_time = time.time()
|
|
96
|
+
while len(batch) < 100:
|
|
97
|
+
elapsed = time.time() - start_time
|
|
98
|
+
if elapsed >= 0.5:
|
|
99
|
+
break
|
|
100
|
+
timeout = 0.5 - elapsed
|
|
101
|
+
try:
|
|
102
|
+
item = self._queue.get(timeout=timeout)
|
|
103
|
+
batch.append(item)
|
|
104
|
+
except queue.Empty:
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if batch:
|
|
108
|
+
self._send_batch(batch)
|
|
109
|
+
for _ in range(len(batch)):
|
|
110
|
+
self._queue.task_done()
|
|
111
|
+
else:
|
|
112
|
+
time.sleep(0.05)
|
|
113
|
+
|
|
114
|
+
def flush(self, timeout: float = 5.0):
|
|
115
|
+
start_time = time.time()
|
|
116
|
+
while not self._queue.empty():
|
|
117
|
+
if time.time() - start_time > timeout:
|
|
118
|
+
break
|
|
119
|
+
batch = []
|
|
120
|
+
while len(batch) < 100:
|
|
121
|
+
try:
|
|
122
|
+
item = self._queue.get_nowait()
|
|
123
|
+
batch.append(item)
|
|
124
|
+
except queue.Empty:
|
|
125
|
+
break
|
|
126
|
+
if batch:
|
|
127
|
+
self._send_batch(batch)
|
|
128
|
+
for _ in range(len(batch)):
|
|
129
|
+
self._queue.task_done()
|
|
130
|
+
else:
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
def stop(self, timeout: float = 5.0):
|
|
134
|
+
self._stop_event.set()
|
|
135
|
+
self.flush(timeout=timeout)
|
|
136
|
+
if self._worker_thread is not None:
|
|
137
|
+
self._worker_thread.join(timeout=timeout)
|
agentlens/context.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""ContextVar-based trace/span ID propagation.
|
|
2
|
+
|
|
3
|
+
These vars are set by the @trace decorator and span() context manager so that
|
|
4
|
+
nested calls automatically inherit the current trace and span context without
|
|
5
|
+
any explicit passing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
_current_trace_id: ContextVar[Optional[str]] = ContextVar("_current_trace_id", default=None)
|
|
14
|
+
_current_span_id: ContextVar[Optional[str]] = ContextVar("_current_span_id", default=None)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_trace_id() -> Optional[str]:
|
|
18
|
+
return _current_trace_id.get()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_span_id() -> Optional[str]:
|
|
22
|
+
return _current_span_id.get()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_trace_id(value: Optional[str]):
|
|
26
|
+
return _current_trace_id.set(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_span_id(value: Optional[str]):
|
|
30
|
+
return _current_span_id.set(value)
|
agentlens/decorators.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Decorators and context managers for AgentLens instrumentation.
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import traceback
|
|
7
|
+
import uuid
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from typing import Any, Callable, List, Optional
|
|
13
|
+
|
|
14
|
+
from agentlens.context import (
|
|
15
|
+
_current_span_id,
|
|
16
|
+
_current_trace_id,
|
|
17
|
+
get_span_id,
|
|
18
|
+
get_trace_id,
|
|
19
|
+
set_span_id,
|
|
20
|
+
set_trace_id,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def trace(name: Optional[str] = None, tags: Optional[List[str]] = None) -> Callable[..., Any]:
|
|
25
|
+
"""Decorator to mark a function as a trace root."""
|
|
26
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
27
|
+
@wraps(func)
|
|
28
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
29
|
+
import agentlens
|
|
30
|
+
if agentlens._global_client is None or not agentlens._global_client.enabled:
|
|
31
|
+
return func(*args, **kwargs)
|
|
32
|
+
|
|
33
|
+
trace_id = str(uuid.uuid4())
|
|
34
|
+
span_id = str(uuid.uuid4())
|
|
35
|
+
|
|
36
|
+
trace_token = set_trace_id(trace_id)
|
|
37
|
+
span_token = set_span_id(span_id)
|
|
38
|
+
|
|
39
|
+
span_name = name or func.__name__
|
|
40
|
+
started_at = datetime.now(timezone.utc)
|
|
41
|
+
start_time = time.time()
|
|
42
|
+
|
|
43
|
+
status = "success"
|
|
44
|
+
error_type = None
|
|
45
|
+
error_message = None
|
|
46
|
+
error_stack = None
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
result = func(*args, **kwargs)
|
|
50
|
+
return result
|
|
51
|
+
except Exception as e:
|
|
52
|
+
status = "error"
|
|
53
|
+
error_type = type(e).__name__
|
|
54
|
+
error_message = str(e)
|
|
55
|
+
error_stack = traceback.format_exc()
|
|
56
|
+
raise e
|
|
57
|
+
finally:
|
|
58
|
+
ended_at = datetime.now(timezone.utc)
|
|
59
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
60
|
+
|
|
61
|
+
span_payload = {
|
|
62
|
+
"id": span_id,
|
|
63
|
+
"trace_id": trace_id,
|
|
64
|
+
"parent_span_id": None,
|
|
65
|
+
"name": span_name,
|
|
66
|
+
"span_type": "custom",
|
|
67
|
+
"status": status,
|
|
68
|
+
"started_at": started_at.isoformat(),
|
|
69
|
+
"ended_at": ended_at.isoformat(),
|
|
70
|
+
"duration_ms": duration_ms,
|
|
71
|
+
"tags": tags or [],
|
|
72
|
+
}
|
|
73
|
+
if error_type:
|
|
74
|
+
span_payload.update({
|
|
75
|
+
"error_type": error_type,
|
|
76
|
+
"error_message": error_message,
|
|
77
|
+
"error_stack": error_stack,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
81
|
+
|
|
82
|
+
_current_trace_id.reset(trace_token)
|
|
83
|
+
_current_span_id.reset(span_token)
|
|
84
|
+
|
|
85
|
+
return wrapper
|
|
86
|
+
return decorator
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Span:
|
|
90
|
+
def __init__(self, id: str, trace_id: str, parent_span_id: Optional[str] = None):
|
|
91
|
+
self.id = id
|
|
92
|
+
self.trace_id = trace_id
|
|
93
|
+
self.parent_span_id = parent_span_id
|
|
94
|
+
self.metadata = {}
|
|
95
|
+
self.input = None
|
|
96
|
+
self.output = None
|
|
97
|
+
self.input_tokens = None
|
|
98
|
+
self.output_tokens = None
|
|
99
|
+
self.model = None
|
|
100
|
+
self.provider = None
|
|
101
|
+
|
|
102
|
+
def set_metadata(self, key_or_dict: Any, value: Any = None):
|
|
103
|
+
if isinstance(key_or_dict, dict):
|
|
104
|
+
for k, v in key_or_dict.items():
|
|
105
|
+
self._set_key(k, v)
|
|
106
|
+
else:
|
|
107
|
+
self._set_key(key_or_dict, value)
|
|
108
|
+
|
|
109
|
+
def _set_key(self, key: str, value: Any):
|
|
110
|
+
if key == "input_tokens":
|
|
111
|
+
self.input_tokens = value
|
|
112
|
+
elif key == "output_tokens":
|
|
113
|
+
self.output_tokens = value
|
|
114
|
+
elif key == "model":
|
|
115
|
+
self.model = value
|
|
116
|
+
elif key == "provider":
|
|
117
|
+
self.provider = value
|
|
118
|
+
else:
|
|
119
|
+
self.metadata[key] = value
|
|
120
|
+
|
|
121
|
+
def set_output(self, output: Any):
|
|
122
|
+
self.output = output
|
|
123
|
+
|
|
124
|
+
def set_input(self, input_val: Any):
|
|
125
|
+
self.input = input_val
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@contextmanager
|
|
129
|
+
def span(name: str, span_type: str = "custom", tags: Optional[List[str]] = None):
|
|
130
|
+
"""Context manager to mark a block of code as a span."""
|
|
131
|
+
import agentlens
|
|
132
|
+
if agentlens._global_client is None or not agentlens._global_client.enabled:
|
|
133
|
+
yield Span(id="", trace_id="")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
# Check or generate a root trace context if none exists
|
|
137
|
+
trace_id = get_trace_id()
|
|
138
|
+
trace_token = None
|
|
139
|
+
if not trace_id:
|
|
140
|
+
trace_id = str(uuid.uuid4())
|
|
141
|
+
trace_token = set_trace_id(trace_id)
|
|
142
|
+
|
|
143
|
+
parent_span_id = get_span_id()
|
|
144
|
+
span_id = str(uuid.uuid4())
|
|
145
|
+
|
|
146
|
+
span_token = set_span_id(span_id)
|
|
147
|
+
|
|
148
|
+
started_at = datetime.now(timezone.utc)
|
|
149
|
+
start_time = time.time()
|
|
150
|
+
|
|
151
|
+
status = "success"
|
|
152
|
+
error_type = None
|
|
153
|
+
error_message = None
|
|
154
|
+
error_stack = None
|
|
155
|
+
|
|
156
|
+
s = Span(id=span_id, trace_id=trace_id, parent_span_id=parent_span_id)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
yield s
|
|
160
|
+
except Exception as e:
|
|
161
|
+
status = "error"
|
|
162
|
+
error_type = type(e).__name__
|
|
163
|
+
error_message = str(e)
|
|
164
|
+
error_stack = traceback.format_exc()
|
|
165
|
+
raise e
|
|
166
|
+
finally:
|
|
167
|
+
ended_at = datetime.now(timezone.utc)
|
|
168
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
169
|
+
|
|
170
|
+
span_payload = {
|
|
171
|
+
"id": span_id,
|
|
172
|
+
"trace_id": trace_id,
|
|
173
|
+
"parent_span_id": parent_span_id,
|
|
174
|
+
"name": name,
|
|
175
|
+
"span_type": span_type,
|
|
176
|
+
"status": status,
|
|
177
|
+
"started_at": started_at.isoformat(),
|
|
178
|
+
"ended_at": ended_at.isoformat(),
|
|
179
|
+
"duration_ms": duration_ms,
|
|
180
|
+
"tags": tags or [],
|
|
181
|
+
}
|
|
182
|
+
if error_type:
|
|
183
|
+
span_payload.update({
|
|
184
|
+
"error_type": error_type,
|
|
185
|
+
"error_message": error_message,
|
|
186
|
+
"error_stack": error_stack,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
if s.input is not None:
|
|
190
|
+
span_payload["input"] = s.input
|
|
191
|
+
if s.output is not None:
|
|
192
|
+
span_payload["output"] = s.output
|
|
193
|
+
if s.metadata:
|
|
194
|
+
span_payload["metadata"] = s.metadata
|
|
195
|
+
if s.input_tokens is not None:
|
|
196
|
+
span_payload["input_tokens"] = s.input_tokens
|
|
197
|
+
if s.output_tokens is not None:
|
|
198
|
+
span_payload["output_tokens"] = s.output_tokens
|
|
199
|
+
if s.model is not None:
|
|
200
|
+
span_payload["model"] = s.model
|
|
201
|
+
if s.provider is not None:
|
|
202
|
+
span_payload["provider"] = s.provider
|
|
203
|
+
|
|
204
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
205
|
+
|
|
206
|
+
_current_span_id.reset(span_token)
|
|
207
|
+
if trace_token:
|
|
208
|
+
_current_trace_id.reset(trace_token)
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Anthropic client instrumentation monkey-patch.
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from agentlens.context import get_span_id, get_trace_id
|
|
12
|
+
from agentlens.pricing import calculate_cost
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def instrument_anthropic():
|
|
16
|
+
"""Monkey-patch Anthropic messages client to automatically record spans."""
|
|
17
|
+
try:
|
|
18
|
+
from anthropic.resources.messages import Messages
|
|
19
|
+
except ImportError:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
if getattr(Messages, "_agentlens_instrumented", False):
|
|
23
|
+
return
|
|
24
|
+
Messages._agentlens_instrumented = True
|
|
25
|
+
|
|
26
|
+
original_create = Messages.create
|
|
27
|
+
|
|
28
|
+
def patched_create(self, *args, **kwargs):
|
|
29
|
+
import agentlens
|
|
30
|
+
if agentlens._global_client is None or not agentlens._global_client.enabled:
|
|
31
|
+
return original_create(self, *args, **kwargs)
|
|
32
|
+
|
|
33
|
+
trace_id = get_trace_id()
|
|
34
|
+
if not trace_id:
|
|
35
|
+
return original_create(self, *args, **kwargs)
|
|
36
|
+
|
|
37
|
+
parent_span_id = get_span_id()
|
|
38
|
+
span_id = str(uuid.uuid4())
|
|
39
|
+
|
|
40
|
+
model = kwargs.get("model", "unknown")
|
|
41
|
+
provider = "anthropic"
|
|
42
|
+
started_at = datetime.now(timezone.utc)
|
|
43
|
+
start_time = time.time()
|
|
44
|
+
messages = kwargs.get("messages", [])
|
|
45
|
+
is_stream = kwargs.get("stream", False)
|
|
46
|
+
|
|
47
|
+
if is_stream:
|
|
48
|
+
try:
|
|
49
|
+
response = original_create(self, *args, **kwargs)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
ended_at = datetime.now(timezone.utc)
|
|
52
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
53
|
+
span_payload = {
|
|
54
|
+
"id": span_id,
|
|
55
|
+
"trace_id": trace_id,
|
|
56
|
+
"parent_span_id": parent_span_id,
|
|
57
|
+
"name": f"anthropic.messages.create ({model})",
|
|
58
|
+
"span_type": "llm",
|
|
59
|
+
"status": "error",
|
|
60
|
+
"started_at": started_at.isoformat(),
|
|
61
|
+
"ended_at": ended_at.isoformat(),
|
|
62
|
+
"duration_ms": duration_ms,
|
|
63
|
+
"model": model,
|
|
64
|
+
"provider": provider,
|
|
65
|
+
"input": {"messages": messages},
|
|
66
|
+
"error_type": type(e).__name__,
|
|
67
|
+
"error_message": str(e),
|
|
68
|
+
"error_stack": traceback.format_exc(),
|
|
69
|
+
}
|
|
70
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
71
|
+
raise e
|
|
72
|
+
|
|
73
|
+
def stream_wrapper(stream_resp):
|
|
74
|
+
accumulated_content = []
|
|
75
|
+
input_tokens = 0
|
|
76
|
+
output_tokens = 0
|
|
77
|
+
try:
|
|
78
|
+
for event in stream_resp:
|
|
79
|
+
yield event
|
|
80
|
+
event_type = getattr(event, "type", None)
|
|
81
|
+
if event_type == "content_block_delta":
|
|
82
|
+
delta = getattr(event, "delta", None)
|
|
83
|
+
if delta and getattr(delta, "type", None) == "text_delta":
|
|
84
|
+
text = getattr(delta, "text", "")
|
|
85
|
+
accumulated_content.append(text)
|
|
86
|
+
elif event_type == "message_start":
|
|
87
|
+
message = getattr(event, "message", None)
|
|
88
|
+
if message and hasattr(message, "usage") and message.usage:
|
|
89
|
+
input_tokens = getattr(message.usage, "input_tokens", 0) or 0
|
|
90
|
+
elif event_type == "message_delta":
|
|
91
|
+
usage = getattr(event, "usage", None)
|
|
92
|
+
if usage:
|
|
93
|
+
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
94
|
+
finally:
|
|
95
|
+
ended_at = datetime.now(timezone.utc)
|
|
96
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
97
|
+
cost_usd = calculate_cost(model, input_tokens, output_tokens)
|
|
98
|
+
full_output = "".join(accumulated_content)
|
|
99
|
+
|
|
100
|
+
span_payload = {
|
|
101
|
+
"id": span_id,
|
|
102
|
+
"trace_id": trace_id,
|
|
103
|
+
"parent_span_id": parent_span_id,
|
|
104
|
+
"name": f"anthropic.messages.create ({model})",
|
|
105
|
+
"span_type": "llm",
|
|
106
|
+
"status": "success",
|
|
107
|
+
"started_at": started_at.isoformat(),
|
|
108
|
+
"ended_at": ended_at.isoformat(),
|
|
109
|
+
"duration_ms": duration_ms,
|
|
110
|
+
"model": model,
|
|
111
|
+
"provider": provider,
|
|
112
|
+
"input_tokens": input_tokens,
|
|
113
|
+
"output_tokens": output_tokens,
|
|
114
|
+
"cost_usd": cost_usd,
|
|
115
|
+
"input": {"messages": messages},
|
|
116
|
+
"output": {"content": [{"type": "text", "text": full_output}]},
|
|
117
|
+
}
|
|
118
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
119
|
+
|
|
120
|
+
return stream_wrapper(response)
|
|
121
|
+
else:
|
|
122
|
+
try:
|
|
123
|
+
response = original_create(self, *args, **kwargs)
|
|
124
|
+
ended_at = datetime.now(timezone.utc)
|
|
125
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
126
|
+
|
|
127
|
+
input_tokens = 0
|
|
128
|
+
output_tokens = 0
|
|
129
|
+
output_content = []
|
|
130
|
+
|
|
131
|
+
if hasattr(response, "usage") and response.usage:
|
|
132
|
+
input_tokens = getattr(response.usage, "input_tokens", 0) or 0
|
|
133
|
+
output_tokens = getattr(response.usage, "output_tokens", 0) or 0
|
|
134
|
+
|
|
135
|
+
if hasattr(response, "content") and response.content:
|
|
136
|
+
for block in response.content:
|
|
137
|
+
block_type = getattr(block, "type", "text")
|
|
138
|
+
if block_type == "text":
|
|
139
|
+
output_content.append({
|
|
140
|
+
"type": "text",
|
|
141
|
+
"text": getattr(block, "text", "")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
cost_usd = calculate_cost(model, input_tokens, output_tokens)
|
|
145
|
+
|
|
146
|
+
span_payload = {
|
|
147
|
+
"id": span_id,
|
|
148
|
+
"trace_id": trace_id,
|
|
149
|
+
"parent_span_id": parent_span_id,
|
|
150
|
+
"name": f"anthropic.messages.create ({model})",
|
|
151
|
+
"span_type": "llm",
|
|
152
|
+
"status": "success",
|
|
153
|
+
"started_at": started_at.isoformat(),
|
|
154
|
+
"ended_at": ended_at.isoformat(),
|
|
155
|
+
"duration_ms": duration_ms,
|
|
156
|
+
"model": model,
|
|
157
|
+
"provider": provider,
|
|
158
|
+
"input_tokens": input_tokens,
|
|
159
|
+
"output_tokens": output_tokens,
|
|
160
|
+
"cost_usd": cost_usd,
|
|
161
|
+
"input": {"messages": messages},
|
|
162
|
+
"output": {"content": output_content},
|
|
163
|
+
}
|
|
164
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
165
|
+
return response
|
|
166
|
+
except Exception as e:
|
|
167
|
+
ended_at = datetime.now(timezone.utc)
|
|
168
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
169
|
+
span_payload = {
|
|
170
|
+
"id": span_id,
|
|
171
|
+
"trace_id": trace_id,
|
|
172
|
+
"parent_span_id": parent_span_id,
|
|
173
|
+
"name": f"anthropic.messages.create ({model})",
|
|
174
|
+
"span_type": "llm",
|
|
175
|
+
"status": "error",
|
|
176
|
+
"started_at": started_at.isoformat(),
|
|
177
|
+
"ended_at": ended_at.isoformat(),
|
|
178
|
+
"duration_ms": duration_ms,
|
|
179
|
+
"model": model,
|
|
180
|
+
"provider": provider,
|
|
181
|
+
"input": {"messages": messages},
|
|
182
|
+
"error_type": type(e).__name__,
|
|
183
|
+
"error_message": str(e),
|
|
184
|
+
"error_stack": traceback.format_exc(),
|
|
185
|
+
}
|
|
186
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
187
|
+
raise e
|
|
188
|
+
|
|
189
|
+
Messages.create = patched_create
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
from anthropic.resources.messages import AsyncMessages
|
|
193
|
+
|
|
194
|
+
original_async_create = AsyncMessages.create
|
|
195
|
+
|
|
196
|
+
async def patched_async_create(self, *args, **kwargs):
|
|
197
|
+
import agentlens
|
|
198
|
+
if agentlens._global_client is None or not agentlens._global_client.enabled:
|
|
199
|
+
return await original_async_create(self, *args, **kwargs)
|
|
200
|
+
|
|
201
|
+
trace_id = get_trace_id()
|
|
202
|
+
if not trace_id:
|
|
203
|
+
return await original_async_create(self, *args, **kwargs)
|
|
204
|
+
|
|
205
|
+
parent_span_id = get_span_id()
|
|
206
|
+
span_id = str(uuid.uuid4())
|
|
207
|
+
|
|
208
|
+
model = kwargs.get("model", "unknown")
|
|
209
|
+
provider = "anthropic"
|
|
210
|
+
started_at = datetime.now(timezone.utc)
|
|
211
|
+
start_time = time.time()
|
|
212
|
+
messages = kwargs.get("messages", [])
|
|
213
|
+
is_stream = kwargs.get("stream", False)
|
|
214
|
+
|
|
215
|
+
if is_stream:
|
|
216
|
+
try:
|
|
217
|
+
response = await original_async_create(self, *args, **kwargs)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
ended_at = datetime.now(timezone.utc)
|
|
220
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
221
|
+
span_payload = {
|
|
222
|
+
"id": span_id,
|
|
223
|
+
"trace_id": trace_id,
|
|
224
|
+
"parent_span_id": parent_span_id,
|
|
225
|
+
"name": f"anthropic.messages.create ({model})",
|
|
226
|
+
"span_type": "llm",
|
|
227
|
+
"status": "error",
|
|
228
|
+
"started_at": started_at.isoformat(),
|
|
229
|
+
"ended_at": ended_at.isoformat(),
|
|
230
|
+
"duration_ms": duration_ms,
|
|
231
|
+
"model": model,
|
|
232
|
+
"provider": provider,
|
|
233
|
+
"input": {"messages": messages},
|
|
234
|
+
"error_type": type(e).__name__,
|
|
235
|
+
"error_message": str(e),
|
|
236
|
+
"error_stack": traceback.format_exc(),
|
|
237
|
+
}
|
|
238
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
239
|
+
raise e
|
|
240
|
+
|
|
241
|
+
async def async_stream_wrapper(stream_resp):
|
|
242
|
+
accumulated_content = []
|
|
243
|
+
input_tokens = 0
|
|
244
|
+
output_tokens = 0
|
|
245
|
+
try:
|
|
246
|
+
async for event in stream_resp:
|
|
247
|
+
yield event
|
|
248
|
+
event_type = getattr(event, "type", None)
|
|
249
|
+
if event_type == "content_block_delta":
|
|
250
|
+
delta = getattr(event, "delta", None)
|
|
251
|
+
if delta and getattr(delta, "type", None) == "text_delta":
|
|
252
|
+
text = getattr(delta, "text", "")
|
|
253
|
+
accumulated_content.append(text)
|
|
254
|
+
elif event_type == "message_start":
|
|
255
|
+
message = getattr(event, "message", None)
|
|
256
|
+
if message and hasattr(message, "usage") and message.usage:
|
|
257
|
+
input_tokens = getattr(message.usage, "input_tokens", 0) or 0
|
|
258
|
+
elif event_type == "message_delta":
|
|
259
|
+
usage = getattr(event, "usage", None)
|
|
260
|
+
if usage:
|
|
261
|
+
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
262
|
+
finally:
|
|
263
|
+
ended_at = datetime.now(timezone.utc)
|
|
264
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
265
|
+
cost_usd = calculate_cost(model, input_tokens, output_tokens)
|
|
266
|
+
full_output = "".join(accumulated_content)
|
|
267
|
+
|
|
268
|
+
span_payload = {
|
|
269
|
+
"id": span_id,
|
|
270
|
+
"trace_id": trace_id,
|
|
271
|
+
"parent_span_id": parent_span_id,
|
|
272
|
+
"name": f"anthropic.messages.create ({model})",
|
|
273
|
+
"span_type": "llm",
|
|
274
|
+
"status": "success",
|
|
275
|
+
"started_at": started_at.isoformat(),
|
|
276
|
+
"ended_at": ended_at.isoformat(),
|
|
277
|
+
"duration_ms": duration_ms,
|
|
278
|
+
"model": model,
|
|
279
|
+
"provider": provider,
|
|
280
|
+
"input_tokens": input_tokens,
|
|
281
|
+
"output_tokens": output_tokens,
|
|
282
|
+
"cost_usd": cost_usd,
|
|
283
|
+
"input": {"messages": messages},
|
|
284
|
+
"output": {"content": [{"type": "text", "text": full_output}]},
|
|
285
|
+
}
|
|
286
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
287
|
+
|
|
288
|
+
return async_stream_wrapper(response)
|
|
289
|
+
else:
|
|
290
|
+
try:
|
|
291
|
+
response = await original_async_create(self, *args, **kwargs)
|
|
292
|
+
ended_at = datetime.now(timezone.utc)
|
|
293
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
294
|
+
|
|
295
|
+
input_tokens = 0
|
|
296
|
+
output_tokens = 0
|
|
297
|
+
output_content = []
|
|
298
|
+
|
|
299
|
+
if hasattr(response, "usage") and response.usage:
|
|
300
|
+
input_tokens = getattr(response.usage, "input_tokens", 0) or 0
|
|
301
|
+
output_tokens = getattr(response.usage, "output_tokens", 0) or 0
|
|
302
|
+
|
|
303
|
+
if hasattr(response, "content") and response.content:
|
|
304
|
+
for block in response.content:
|
|
305
|
+
block_type = getattr(block, "type", "text")
|
|
306
|
+
if block_type == "text":
|
|
307
|
+
output_content.append({
|
|
308
|
+
"type": "text",
|
|
309
|
+
"text": getattr(block, "text", "")
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
cost_usd = calculate_cost(model, input_tokens, output_tokens)
|
|
313
|
+
|
|
314
|
+
span_payload = {
|
|
315
|
+
"id": span_id,
|
|
316
|
+
"trace_id": trace_id,
|
|
317
|
+
"parent_span_id": parent_span_id,
|
|
318
|
+
"name": f"anthropic.messages.create ({model})",
|
|
319
|
+
"span_type": "llm",
|
|
320
|
+
"status": "success",
|
|
321
|
+
"started_at": started_at.isoformat(),
|
|
322
|
+
"ended_at": ended_at.isoformat(),
|
|
323
|
+
"duration_ms": duration_ms,
|
|
324
|
+
"model": model,
|
|
325
|
+
"provider": provider,
|
|
326
|
+
"input_tokens": input_tokens,
|
|
327
|
+
"output_tokens": output_tokens,
|
|
328
|
+
"cost_usd": cost_usd,
|
|
329
|
+
"input": {"messages": messages},
|
|
330
|
+
"output": {"content": output_content},
|
|
331
|
+
}
|
|
332
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
333
|
+
return response
|
|
334
|
+
except Exception as e:
|
|
335
|
+
ended_at = datetime.now(timezone.utc)
|
|
336
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
337
|
+
span_payload = {
|
|
338
|
+
"id": span_id,
|
|
339
|
+
"trace_id": trace_id,
|
|
340
|
+
"parent_span_id": parent_span_id,
|
|
341
|
+
"name": f"anthropic.messages.create ({model})",
|
|
342
|
+
"span_type": "llm",
|
|
343
|
+
"status": "error",
|
|
344
|
+
"started_at": started_at.isoformat(),
|
|
345
|
+
"ended_at": ended_at.isoformat(),
|
|
346
|
+
"duration_ms": duration_ms,
|
|
347
|
+
"model": model,
|
|
348
|
+
"provider": provider,
|
|
349
|
+
"input": {"messages": messages},
|
|
350
|
+
"error_type": type(e).__name__,
|
|
351
|
+
"error_message": str(e),
|
|
352
|
+
"error_stack": traceback.format_exc(),
|
|
353
|
+
}
|
|
354
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
355
|
+
raise e
|
|
356
|
+
|
|
357
|
+
AsyncMessages.create = patched_async_create
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""OpenAI client instrumentation monkey-patch.
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from agentlens.context import get_span_id, get_trace_id
|
|
12
|
+
from agentlens.pricing import calculate_cost
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def instrument_openai():
|
|
16
|
+
"""Monkey-patch OpenAI chat completions to automatically record spans."""
|
|
17
|
+
try:
|
|
18
|
+
from openai.resources.chat.completions import Completions
|
|
19
|
+
except ImportError:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
if getattr(Completions, "_agentlens_instrumented", False):
|
|
23
|
+
return
|
|
24
|
+
Completions._agentlens_instrumented = True
|
|
25
|
+
|
|
26
|
+
original_create = Completions.create
|
|
27
|
+
|
|
28
|
+
def patched_create(self, *args, **kwargs):
|
|
29
|
+
import agentlens
|
|
30
|
+
if agentlens._global_client is None or not agentlens._global_client.enabled:
|
|
31
|
+
return original_create(self, *args, **kwargs)
|
|
32
|
+
|
|
33
|
+
trace_id = get_trace_id()
|
|
34
|
+
if not trace_id:
|
|
35
|
+
return original_create(self, *args, **kwargs)
|
|
36
|
+
|
|
37
|
+
parent_span_id = get_span_id()
|
|
38
|
+
span_id = str(uuid.uuid4())
|
|
39
|
+
|
|
40
|
+
model = kwargs.get("model", "unknown")
|
|
41
|
+
provider = "openai"
|
|
42
|
+
started_at = datetime.now(timezone.utc)
|
|
43
|
+
start_time = time.time()
|
|
44
|
+
messages = kwargs.get("messages", [])
|
|
45
|
+
is_stream = kwargs.get("stream", False)
|
|
46
|
+
|
|
47
|
+
if is_stream:
|
|
48
|
+
stream_options = kwargs.get("stream_options", {}) or {}
|
|
49
|
+
stream_options["include_usage"] = True
|
|
50
|
+
kwargs["stream_options"] = stream_options
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
response = original_create(self, *args, **kwargs)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
ended_at = datetime.now(timezone.utc)
|
|
56
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
57
|
+
span_payload = {
|
|
58
|
+
"id": span_id,
|
|
59
|
+
"trace_id": trace_id,
|
|
60
|
+
"parent_span_id": parent_span_id,
|
|
61
|
+
"name": f"openai.chat.completions.create ({model})",
|
|
62
|
+
"span_type": "llm",
|
|
63
|
+
"status": "error",
|
|
64
|
+
"started_at": started_at.isoformat(),
|
|
65
|
+
"ended_at": ended_at.isoformat(),
|
|
66
|
+
"duration_ms": duration_ms,
|
|
67
|
+
"model": model,
|
|
68
|
+
"provider": provider,
|
|
69
|
+
"input": {"messages": messages},
|
|
70
|
+
"error_type": type(e).__name__,
|
|
71
|
+
"error_message": str(e),
|
|
72
|
+
"error_stack": traceback.format_exc(),
|
|
73
|
+
}
|
|
74
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
75
|
+
raise e
|
|
76
|
+
|
|
77
|
+
def stream_wrapper(stream_resp):
|
|
78
|
+
accumulated_content = []
|
|
79
|
+
prompt_tokens = 0
|
|
80
|
+
completion_tokens = 0
|
|
81
|
+
try:
|
|
82
|
+
for chunk in stream_resp:
|
|
83
|
+
yield chunk
|
|
84
|
+
if hasattr(chunk, "choices") and chunk.choices:
|
|
85
|
+
choice = chunk.choices[0]
|
|
86
|
+
if hasattr(choice, "delta") and choice.delta:
|
|
87
|
+
if hasattr(choice.delta, "content") and choice.delta.content:
|
|
88
|
+
accumulated_content.append(choice.delta.content)
|
|
89
|
+
if hasattr(chunk, "usage") and chunk.usage:
|
|
90
|
+
prompt_tokens = getattr(chunk.usage, "prompt_tokens", 0) or 0
|
|
91
|
+
completion_tokens = getattr(chunk.usage, "completion_tokens", 0) or 0
|
|
92
|
+
finally:
|
|
93
|
+
ended_at = datetime.now(timezone.utc)
|
|
94
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
95
|
+
cost_usd = calculate_cost(model, prompt_tokens, completion_tokens)
|
|
96
|
+
full_output = "".join(accumulated_content)
|
|
97
|
+
|
|
98
|
+
span_payload = {
|
|
99
|
+
"id": span_id,
|
|
100
|
+
"trace_id": trace_id,
|
|
101
|
+
"parent_span_id": parent_span_id,
|
|
102
|
+
"name": f"openai.chat.completions.create ({model})",
|
|
103
|
+
"span_type": "llm",
|
|
104
|
+
"status": "success",
|
|
105
|
+
"started_at": started_at.isoformat(),
|
|
106
|
+
"ended_at": ended_at.isoformat(),
|
|
107
|
+
"duration_ms": duration_ms,
|
|
108
|
+
"model": model,
|
|
109
|
+
"provider": provider,
|
|
110
|
+
"input_tokens": prompt_tokens,
|
|
111
|
+
"output_tokens": completion_tokens,
|
|
112
|
+
"cost_usd": cost_usd,
|
|
113
|
+
"input": {"messages": messages},
|
|
114
|
+
"output": {
|
|
115
|
+
"choices": [
|
|
116
|
+
{
|
|
117
|
+
"message": {
|
|
118
|
+
"role": "assistant",
|
|
119
|
+
"content": full_output,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
126
|
+
|
|
127
|
+
return stream_wrapper(response)
|
|
128
|
+
else:
|
|
129
|
+
try:
|
|
130
|
+
response = original_create(self, *args, **kwargs)
|
|
131
|
+
ended_at = datetime.now(timezone.utc)
|
|
132
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
133
|
+
|
|
134
|
+
prompt_tokens = 0
|
|
135
|
+
completion_tokens = 0
|
|
136
|
+
output_message = {}
|
|
137
|
+
if hasattr(response, "usage") and response.usage:
|
|
138
|
+
prompt_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
|
|
139
|
+
completion_tokens = getattr(response.usage, "completion_tokens", 0) or 0
|
|
140
|
+
if hasattr(response, "choices") and response.choices:
|
|
141
|
+
choice = response.choices[0]
|
|
142
|
+
if hasattr(choice, "message") and choice.message:
|
|
143
|
+
output_message = {
|
|
144
|
+
"role": getattr(choice.message, "role", "assistant"),
|
|
145
|
+
"content": getattr(choice.message, "content", ""),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
cost_usd = calculate_cost(model, prompt_tokens, completion_tokens)
|
|
149
|
+
|
|
150
|
+
span_payload = {
|
|
151
|
+
"id": span_id,
|
|
152
|
+
"trace_id": trace_id,
|
|
153
|
+
"parent_span_id": parent_span_id,
|
|
154
|
+
"name": f"openai.chat.completions.create ({model})",
|
|
155
|
+
"span_type": "llm",
|
|
156
|
+
"status": "success",
|
|
157
|
+
"started_at": started_at.isoformat(),
|
|
158
|
+
"ended_at": ended_at.isoformat(),
|
|
159
|
+
"duration_ms": duration_ms,
|
|
160
|
+
"model": model,
|
|
161
|
+
"provider": provider,
|
|
162
|
+
"input_tokens": prompt_tokens,
|
|
163
|
+
"output_tokens": completion_tokens,
|
|
164
|
+
"cost_usd": cost_usd,
|
|
165
|
+
"input": {"messages": messages},
|
|
166
|
+
"output": {"choices": [{"message": output_message}]},
|
|
167
|
+
}
|
|
168
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
169
|
+
return response
|
|
170
|
+
except Exception as e:
|
|
171
|
+
ended_at = datetime.now(timezone.utc)
|
|
172
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
173
|
+
span_payload = {
|
|
174
|
+
"id": span_id,
|
|
175
|
+
"trace_id": trace_id,
|
|
176
|
+
"parent_span_id": parent_span_id,
|
|
177
|
+
"name": f"openai.chat.completions.create ({model})",
|
|
178
|
+
"span_type": "llm",
|
|
179
|
+
"status": "error",
|
|
180
|
+
"started_at": started_at.isoformat(),
|
|
181
|
+
"ended_at": ended_at.isoformat(),
|
|
182
|
+
"duration_ms": duration_ms,
|
|
183
|
+
"model": model,
|
|
184
|
+
"provider": provider,
|
|
185
|
+
"input": {"messages": messages},
|
|
186
|
+
"error_type": type(e).__name__,
|
|
187
|
+
"error_message": str(e),
|
|
188
|
+
"error_stack": traceback.format_exc(),
|
|
189
|
+
}
|
|
190
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
191
|
+
raise e
|
|
192
|
+
|
|
193
|
+
Completions.create = patched_create
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
from openai.resources.chat.completions import AsyncCompletions
|
|
197
|
+
|
|
198
|
+
original_async_create = AsyncCompletions.create
|
|
199
|
+
|
|
200
|
+
async def patched_async_create(self, *args, **kwargs):
|
|
201
|
+
import agentlens
|
|
202
|
+
if agentlens._global_client is None or not agentlens._global_client.enabled:
|
|
203
|
+
return await original_async_create(self, *args, **kwargs)
|
|
204
|
+
|
|
205
|
+
trace_id = get_trace_id()
|
|
206
|
+
if not trace_id:
|
|
207
|
+
return await original_async_create(self, *args, **kwargs)
|
|
208
|
+
|
|
209
|
+
parent_span_id = get_span_id()
|
|
210
|
+
span_id = str(uuid.uuid4())
|
|
211
|
+
|
|
212
|
+
model = kwargs.get("model", "unknown")
|
|
213
|
+
provider = "openai"
|
|
214
|
+
started_at = datetime.now(timezone.utc)
|
|
215
|
+
start_time = time.time()
|
|
216
|
+
messages = kwargs.get("messages", [])
|
|
217
|
+
is_stream = kwargs.get("stream", False)
|
|
218
|
+
|
|
219
|
+
if is_stream:
|
|
220
|
+
stream_options = kwargs.get("stream_options", {}) or {}
|
|
221
|
+
stream_options["include_usage"] = True
|
|
222
|
+
kwargs["stream_options"] = stream_options
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
response = await original_async_create(self, *args, **kwargs)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
ended_at = datetime.now(timezone.utc)
|
|
228
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
229
|
+
span_payload = {
|
|
230
|
+
"id": span_id,
|
|
231
|
+
"trace_id": trace_id,
|
|
232
|
+
"parent_span_id": parent_span_id,
|
|
233
|
+
"name": f"openai.chat.completions.create ({model})",
|
|
234
|
+
"span_type": "llm",
|
|
235
|
+
"status": "error",
|
|
236
|
+
"started_at": started_at.isoformat(),
|
|
237
|
+
"ended_at": ended_at.isoformat(),
|
|
238
|
+
"duration_ms": duration_ms,
|
|
239
|
+
"model": model,
|
|
240
|
+
"provider": provider,
|
|
241
|
+
"input": {"messages": messages},
|
|
242
|
+
"error_type": type(e).__name__,
|
|
243
|
+
"error_message": str(e),
|
|
244
|
+
"error_stack": traceback.format_exc(),
|
|
245
|
+
}
|
|
246
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
247
|
+
raise e
|
|
248
|
+
|
|
249
|
+
async def async_stream_wrapper(stream_resp):
|
|
250
|
+
accumulated_content = []
|
|
251
|
+
prompt_tokens = 0
|
|
252
|
+
completion_tokens = 0
|
|
253
|
+
try:
|
|
254
|
+
async for chunk in stream_resp:
|
|
255
|
+
yield chunk
|
|
256
|
+
if hasattr(chunk, "choices") and chunk.choices:
|
|
257
|
+
choice = chunk.choices[0]
|
|
258
|
+
if hasattr(choice, "delta") and choice.delta:
|
|
259
|
+
if hasattr(choice.delta, "content") and choice.delta.content:
|
|
260
|
+
accumulated_content.append(choice.delta.content)
|
|
261
|
+
if hasattr(chunk, "usage") and chunk.usage:
|
|
262
|
+
prompt_tokens = getattr(chunk.usage, "prompt_tokens", 0) or 0
|
|
263
|
+
completion_tokens = getattr(chunk.usage, "completion_tokens", 0) or 0
|
|
264
|
+
finally:
|
|
265
|
+
ended_at = datetime.now(timezone.utc)
|
|
266
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
267
|
+
cost_usd = calculate_cost(model, prompt_tokens, completion_tokens)
|
|
268
|
+
full_output = "".join(accumulated_content)
|
|
269
|
+
|
|
270
|
+
span_payload = {
|
|
271
|
+
"id": span_id,
|
|
272
|
+
"trace_id": trace_id,
|
|
273
|
+
"parent_span_id": parent_span_id,
|
|
274
|
+
"name": f"openai.chat.completions.create ({model})",
|
|
275
|
+
"span_type": "llm",
|
|
276
|
+
"status": "success",
|
|
277
|
+
"started_at": started_at.isoformat(),
|
|
278
|
+
"ended_at": ended_at.isoformat(),
|
|
279
|
+
"duration_ms": duration_ms,
|
|
280
|
+
"model": model,
|
|
281
|
+
"provider": provider,
|
|
282
|
+
"input_tokens": prompt_tokens,
|
|
283
|
+
"output_tokens": completion_tokens,
|
|
284
|
+
"cost_usd": cost_usd,
|
|
285
|
+
"input": {"messages": messages},
|
|
286
|
+
"output": {
|
|
287
|
+
"choices": [
|
|
288
|
+
{
|
|
289
|
+
"message": {
|
|
290
|
+
"role": "assistant",
|
|
291
|
+
"content": full_output,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
]
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
298
|
+
|
|
299
|
+
return async_stream_wrapper(response)
|
|
300
|
+
else:
|
|
301
|
+
try:
|
|
302
|
+
response = await original_async_create(self, *args, **kwargs)
|
|
303
|
+
ended_at = datetime.now(timezone.utc)
|
|
304
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
305
|
+
|
|
306
|
+
prompt_tokens = 0
|
|
307
|
+
completion_tokens = 0
|
|
308
|
+
output_message = {}
|
|
309
|
+
if hasattr(response, "usage") and response.usage:
|
|
310
|
+
prompt_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
|
|
311
|
+
completion_tokens = getattr(response.usage, "completion_tokens", 0) or 0
|
|
312
|
+
if hasattr(response, "choices") and response.choices:
|
|
313
|
+
choice = response.choices[0]
|
|
314
|
+
if hasattr(choice, "message") and choice.message:
|
|
315
|
+
output_message = {
|
|
316
|
+
"role": getattr(choice.message, "role", "assistant"),
|
|
317
|
+
"content": getattr(choice.message, "content", ""),
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
cost_usd = calculate_cost(model, prompt_tokens, completion_tokens)
|
|
321
|
+
|
|
322
|
+
span_payload = {
|
|
323
|
+
"id": span_id,
|
|
324
|
+
"trace_id": trace_id,
|
|
325
|
+
"parent_span_id": parent_span_id,
|
|
326
|
+
"name": f"openai.chat.completions.create ({model})",
|
|
327
|
+
"span_type": "llm",
|
|
328
|
+
"status": "success",
|
|
329
|
+
"started_at": started_at.isoformat(),
|
|
330
|
+
"ended_at": ended_at.isoformat(),
|
|
331
|
+
"duration_ms": duration_ms,
|
|
332
|
+
"model": model,
|
|
333
|
+
"provider": provider,
|
|
334
|
+
"input_tokens": prompt_tokens,
|
|
335
|
+
"output_tokens": completion_tokens,
|
|
336
|
+
"cost_usd": cost_usd,
|
|
337
|
+
"input": {"messages": messages},
|
|
338
|
+
"output": {"choices": [{"message": output_message}]},
|
|
339
|
+
}
|
|
340
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
341
|
+
return response
|
|
342
|
+
except Exception as e:
|
|
343
|
+
ended_at = datetime.now(timezone.utc)
|
|
344
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
345
|
+
span_payload = {
|
|
346
|
+
"id": span_id,
|
|
347
|
+
"trace_id": trace_id,
|
|
348
|
+
"parent_span_id": parent_span_id,
|
|
349
|
+
"name": f"openai.chat.completions.create ({model})",
|
|
350
|
+
"span_type": "llm",
|
|
351
|
+
"status": "error",
|
|
352
|
+
"started_at": started_at.isoformat(),
|
|
353
|
+
"ended_at": ended_at.isoformat(),
|
|
354
|
+
"duration_ms": duration_ms,
|
|
355
|
+
"model": model,
|
|
356
|
+
"provider": provider,
|
|
357
|
+
"input": {"messages": messages},
|
|
358
|
+
"error_type": type(e).__name__,
|
|
359
|
+
"error_message": str(e),
|
|
360
|
+
"error_stack": traceback.format_exc(),
|
|
361
|
+
}
|
|
362
|
+
agentlens._global_client._safe_enqueue(span_payload)
|
|
363
|
+
raise e
|
|
364
|
+
|
|
365
|
+
AsyncCompletions.create = patched_async_create
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
agentlens/pricing.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Token cost lookup and calculation for LLM spans.
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("agentlens")
|
|
9
|
+
|
|
10
|
+
PRICING = {
|
|
11
|
+
"gpt-4o": {"input": 5.0 / 1_000_000, "output": 15.0 / 1_000_000},
|
|
12
|
+
"gpt-4o-mini": {"input": 0.15 / 1_000_000, "output": 0.60 / 1_000_000},
|
|
13
|
+
"gpt-4-turbo": {"input": 10.0 / 1_000_000, "output": 30.0 / 1_000_000},
|
|
14
|
+
"gpt-4": {"input": 30.0 / 1_000_000, "output": 60.0 / 1_000_000},
|
|
15
|
+
"gpt-3.5-turbo": {"input": 0.50 / 1_000_000, "output": 1.50 / 1_000_000},
|
|
16
|
+
"gpt-3.5-turbo-0125": {"input": 0.50 / 1_000_000, "output": 1.50 / 1_000_000},
|
|
17
|
+
"claude-3-5-sonnet-20241022": {"input": 3.0 / 1_000_000, "output": 15.0 / 1_000_000},
|
|
18
|
+
"claude-3-5-haiku-20241022": {"input": 0.80 / 1_000_000, "output": 4.0 / 1_000_000},
|
|
19
|
+
"claude-3-opus-20240229": {"input": 15.0 / 1_000_000, "output": 75.0 / 1_000_000},
|
|
20
|
+
"claude-3-sonnet-20240229": {"input": 3.0 / 1_000_000, "output": 15.0 / 1_000_000},
|
|
21
|
+
"claude-3-haiku-20240307": {"input": 0.25 / 1_000_000, "output": 1.25 / 1_000_000},
|
|
22
|
+
# Verified from cometapi.com/models on 2026-06-27 — re-check periodically,
|
|
23
|
+
# CometAPI pricing can change.
|
|
24
|
+
"glm-5.2": {"input": 1.12 / 1_000_000, "output": 3.528 / 1_000_000},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
|
|
29
|
+
"""Calculate the cost in USD for a given model and token count."""
|
|
30
|
+
rates = PRICING.get(model)
|
|
31
|
+
if not rates:
|
|
32
|
+
logger.debug(f"Unknown model name for cost calculation: {model}")
|
|
33
|
+
return 0.0
|
|
34
|
+
|
|
35
|
+
in_cost = input_tokens * rates["input"]
|
|
36
|
+
out_cost = output_tokens * rates["output"]
|
|
37
|
+
return in_cost + out_cost
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentlens-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AgentLens Python SDK — zero-overhead observability for AI agents
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: httpx==0.28.1
|
|
7
|
+
Requires-Dist: structlog==25.5.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: hypothesis==6.152.9; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest-asyncio==1.3.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest==9.0.3; extra == 'dev'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
agentlens/__init__.py,sha256=qA3mXgZnimMnis_Nzce6oMrBoE77MPF5OUe_D93P_Ns,1621
|
|
2
|
+
agentlens/client.py,sha256=0BuNl7MStYr0xhbJ_lWrXRSwA3mGgXDN1X6KGaT_mxM,4466
|
|
3
|
+
agentlens/context.py,sha256=-8OGFgS7tQ8HbOiRogRl5YLEEMAsfDFBUXkMhOWdQLA,853
|
|
4
|
+
agentlens/decorators.py,sha256=9batbDAXMhqqB_4VCJcLEKGnXtKKN2FVd5q9XUiXGO4,6740
|
|
5
|
+
agentlens/pricing.py,sha256=7z4KZeIYGn4_wbP9YGd8qBj6gmQPB76xSTWHJhOR598,1702
|
|
6
|
+
agentlens/instruments/anthropic.py,sha256=ZVM48PQyGd0AqPJSfY-uRMPxP23P_JbKwk3ssg4zLXo,16743
|
|
7
|
+
agentlens/instruments/openai.py,sha256=u4hqZ4XWQLSl4q0ZW3TA_SRO3m1GePgWB33OGWpHUSQ,17217
|
|
8
|
+
agentlens_py-0.1.0.dist-info/METADATA,sha256=YlI6w6Vrd8jmU7LxJgYiysJ-pDN7cPRDzdk5iFNkh20,386
|
|
9
|
+
agentlens_py-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
agentlens_py-0.1.0.dist-info/RECORD,,
|