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 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)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any