agentreplay 0.1.2__py3-none-any.whl → 0.1.3__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.
@@ -0,0 +1,522 @@
1
+ # Copyright 2025 Sushanth (https://github.com/sushanthpy)
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ SDK client wrappers for automatic instrumentation.
17
+
18
+ Provides one-liner wrappers for popular LLM SDKs that automatically
19
+ trace all API calls without code changes.
20
+
21
+ Example:
22
+ >>> from openai import OpenAI
23
+ >>> from agentreplay import init, wrap_openai
24
+ >>>
25
+ >>> init()
26
+ >>> client = wrap_openai(OpenAI())
27
+ >>>
28
+ >>> # All calls are now traced automatically!
29
+ >>> response = client.chat.completions.create(
30
+ ... model="gpt-4",
31
+ ... messages=[{"role": "user", "content": "Hello!"}]
32
+ ... )
33
+ """
34
+
35
+ import functools
36
+ import time
37
+ import logging
38
+ from typing import TypeVar, Any, Optional, Callable, Dict
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ T = TypeVar("T")
43
+
44
+
45
+ # =============================================================================
46
+ # OpenAI Wrapper
47
+ # =============================================================================
48
+
49
+ def wrap_openai(client: T, *, capture_content: bool = True) -> T:
50
+ """Wrap an OpenAI client for automatic tracing.
51
+
52
+ Traces all chat.completions.create() and completions.create() calls,
53
+ automatically capturing:
54
+ - Model name
55
+ - Messages/prompt
56
+ - Response content
57
+ - Token usage
58
+ - Latency
59
+ - Errors
60
+
61
+ Args:
62
+ client: OpenAI client instance
63
+ capture_content: Whether to capture message content (disable for privacy)
64
+
65
+ Returns:
66
+ Wrapped client with same interface
67
+
68
+ Example:
69
+ >>> from openai import OpenAI
70
+ >>> from agentreplay import wrap_openai
71
+ >>>
72
+ >>> client = wrap_openai(OpenAI())
73
+ >>>
74
+ >>> response = client.chat.completions.create(
75
+ ... model="gpt-4",
76
+ ... messages=[{"role": "user", "content": "Hello!"}]
77
+ ... ) # Automatically traced!
78
+ """
79
+ try:
80
+ from agentreplay.decorators import ActiveSpan, SpanKind, _current_span
81
+
82
+ # Get original methods
83
+ original_chat_create = client.chat.completions.create
84
+ original_chat_create_async = getattr(
85
+ getattr(client, "chat", None),
86
+ "completions", None
87
+ )
88
+
89
+ # Check if async client
90
+ is_async = hasattr(client, "_async_client") or "AsyncOpenAI" in type(client).__name__
91
+
92
+ if is_async:
93
+ return _wrap_openai_async(client, capture_content)
94
+
95
+ # Wrap sync chat.completions.create
96
+ @functools.wraps(original_chat_create)
97
+ def wrapped_chat_create(*args, **kwargs):
98
+ # Get parent span
99
+ parent = _current_span.get()
100
+
101
+ # Create span
102
+ span = ActiveSpan(
103
+ name="openai.chat.completions.create",
104
+ kind=SpanKind.LLM,
105
+ parent_id=parent.span_id if parent else None,
106
+ trace_id=parent.trace_id if parent else None,
107
+ )
108
+
109
+ # Set attributes
110
+ model = kwargs.get("model", "unknown")
111
+ span.set_model(model, provider="openai")
112
+ span.set_attribute("llm.request.type", "chat")
113
+
114
+ # Capture input
115
+ if capture_content:
116
+ messages = kwargs.get("messages", [])
117
+ span.set_input({"messages": messages})
118
+
119
+ with span:
120
+ try:
121
+ response = original_chat_create(*args, **kwargs)
122
+
123
+ # Capture output
124
+ if capture_content and hasattr(response, "choices") and response.choices:
125
+ output_content = response.choices[0].message.content
126
+ span.set_output({"content": output_content})
127
+
128
+ # Capture token usage
129
+ if hasattr(response, "usage") and response.usage:
130
+ span.set_token_usage(
131
+ prompt_tokens=response.usage.prompt_tokens,
132
+ completion_tokens=response.usage.completion_tokens,
133
+ total_tokens=response.usage.total_tokens,
134
+ )
135
+
136
+ # Capture finish reason
137
+ if hasattr(response, "choices") and response.choices:
138
+ span.set_attribute(
139
+ "llm.response.finish_reason",
140
+ response.choices[0].finish_reason
141
+ )
142
+
143
+ return response
144
+
145
+ except Exception as e:
146
+ span.set_error(e)
147
+ raise
148
+
149
+ # Monkey-patch
150
+ client.chat.completions.create = wrapped_chat_create
151
+
152
+ # Also wrap embeddings if present
153
+ if hasattr(client, "embeddings"):
154
+ _wrap_openai_embeddings(client, capture_content)
155
+
156
+ return client
157
+
158
+ except Exception as e:
159
+ logger.warning(f"Failed to wrap OpenAI client: {e}. Returning unwrapped client.")
160
+ return client
161
+
162
+
163
+ def _wrap_openai_async(client: T, capture_content: bool) -> T:
164
+ """Wrap async OpenAI client."""
165
+ try:
166
+ from agentreplay.decorators import ActiveSpan, SpanKind, _current_span
167
+
168
+ original_chat_create = client.chat.completions.create
169
+
170
+ @functools.wraps(original_chat_create)
171
+ async def wrapped_chat_create(*args, **kwargs):
172
+ parent = _current_span.get()
173
+
174
+ span = ActiveSpan(
175
+ name="openai.chat.completions.create",
176
+ kind=SpanKind.LLM,
177
+ parent_id=parent.span_id if parent else None,
178
+ trace_id=parent.trace_id if parent else None,
179
+ )
180
+
181
+ model = kwargs.get("model", "unknown")
182
+ span.set_model(model, provider="openai")
183
+ span.set_attribute("llm.request.type", "chat")
184
+
185
+ if capture_content:
186
+ messages = kwargs.get("messages", [])
187
+ span.set_input({"messages": messages})
188
+
189
+ with span:
190
+ try:
191
+ response = await original_chat_create(*args, **kwargs)
192
+
193
+ if capture_content and hasattr(response, "choices") and response.choices:
194
+ output_content = response.choices[0].message.content
195
+ span.set_output({"content": output_content})
196
+
197
+ if hasattr(response, "usage") and response.usage:
198
+ span.set_token_usage(
199
+ prompt_tokens=response.usage.prompt_tokens,
200
+ completion_tokens=response.usage.completion_tokens,
201
+ total_tokens=response.usage.total_tokens,
202
+ )
203
+
204
+ if hasattr(response, "choices") and response.choices:
205
+ span.set_attribute(
206
+ "llm.response.finish_reason",
207
+ response.choices[0].finish_reason
208
+ )
209
+
210
+ return response
211
+
212
+ except Exception as e:
213
+ span.set_error(e)
214
+ raise
215
+
216
+ client.chat.completions.create = wrapped_chat_create
217
+ return client
218
+
219
+ except Exception as e:
220
+ logger.warning(f"Failed to wrap async OpenAI client: {e}")
221
+ return client
222
+
223
+
224
+ def _wrap_openai_embeddings(client: T, capture_content: bool) -> None:
225
+ """Wrap OpenAI embeddings."""
226
+ try:
227
+ from agentreplay.decorators import ActiveSpan, SpanKind, _current_span
228
+
229
+ original_create = client.embeddings.create
230
+
231
+ @functools.wraps(original_create)
232
+ def wrapped_create(*args, **kwargs):
233
+ parent = _current_span.get()
234
+
235
+ span = ActiveSpan(
236
+ name="openai.embeddings.create",
237
+ kind=SpanKind.EMBEDDING,
238
+ parent_id=parent.span_id if parent else None,
239
+ trace_id=parent.trace_id if parent else None,
240
+ )
241
+
242
+ model = kwargs.get("model", "text-embedding-ada-002")
243
+ span.set_model(model, provider="openai")
244
+ span.set_attribute("llm.request.type", "embedding")
245
+
246
+ # Capture input count (not content for privacy)
247
+ input_data = kwargs.get("input", [])
248
+ if isinstance(input_data, str):
249
+ span.set_attribute("embedding.input_count", 1)
250
+ else:
251
+ span.set_attribute("embedding.input_count", len(input_data))
252
+
253
+ with span:
254
+ try:
255
+ response = original_create(*args, **kwargs)
256
+
257
+ if hasattr(response, "usage") and response.usage:
258
+ span.set_token_usage(
259
+ prompt_tokens=response.usage.prompt_tokens,
260
+ total_tokens=response.usage.total_tokens,
261
+ )
262
+
263
+ if hasattr(response, "data"):
264
+ span.set_attribute("embedding.output_count", len(response.data))
265
+
266
+ return response
267
+
268
+ except Exception as e:
269
+ span.set_error(e)
270
+ raise
271
+
272
+ client.embeddings.create = wrapped_create
273
+
274
+ except Exception as e:
275
+ logger.debug(f"Failed to wrap embeddings: {e}")
276
+
277
+
278
+ # =============================================================================
279
+ # Anthropic Wrapper
280
+ # =============================================================================
281
+
282
+ def wrap_anthropic(client: T, *, capture_content: bool = True) -> T:
283
+ """Wrap an Anthropic client for automatic tracing.
284
+
285
+ Traces all messages.create() calls, automatically capturing:
286
+ - Model name
287
+ - Messages/prompt
288
+ - Response content
289
+ - Token usage
290
+ - Latency
291
+ - Errors
292
+
293
+ Args:
294
+ client: Anthropic client instance
295
+ capture_content: Whether to capture message content
296
+
297
+ Returns:
298
+ Wrapped client with same interface
299
+
300
+ Example:
301
+ >>> from anthropic import Anthropic
302
+ >>> from agentreplay import wrap_anthropic
303
+ >>>
304
+ >>> client = wrap_anthropic(Anthropic())
305
+ >>>
306
+ >>> message = client.messages.create(
307
+ ... model="claude-3-opus-20240229",
308
+ ... messages=[{"role": "user", "content": "Hello!"}]
309
+ ... ) # Automatically traced!
310
+ """
311
+ try:
312
+ from agentreplay.decorators import ActiveSpan, SpanKind, _current_span
313
+
314
+ # Check if async
315
+ is_async = "AsyncAnthropic" in type(client).__name__
316
+
317
+ if is_async:
318
+ return _wrap_anthropic_async(client, capture_content)
319
+
320
+ original_messages_create = client.messages.create
321
+
322
+ @functools.wraps(original_messages_create)
323
+ def wrapped_messages_create(*args, **kwargs):
324
+ parent = _current_span.get()
325
+
326
+ span = ActiveSpan(
327
+ name="anthropic.messages.create",
328
+ kind=SpanKind.LLM,
329
+ parent_id=parent.span_id if parent else None,
330
+ trace_id=parent.trace_id if parent else None,
331
+ )
332
+
333
+ model = kwargs.get("model", "claude-3")
334
+ span.set_model(model, provider="anthropic")
335
+ span.set_attribute("llm.request.type", "chat")
336
+
337
+ if capture_content:
338
+ messages = kwargs.get("messages", [])
339
+ system = kwargs.get("system")
340
+ input_data = {"messages": messages}
341
+ if system:
342
+ input_data["system"] = system
343
+ span.set_input(input_data)
344
+
345
+ with span:
346
+ try:
347
+ response = original_messages_create(*args, **kwargs)
348
+
349
+ if capture_content and hasattr(response, "content") and response.content:
350
+ content = response.content[0]
351
+ if hasattr(content, "text"):
352
+ span.set_output({"content": content.text})
353
+
354
+ if hasattr(response, "usage"):
355
+ span.set_token_usage(
356
+ prompt_tokens=response.usage.input_tokens,
357
+ completion_tokens=response.usage.output_tokens,
358
+ )
359
+
360
+ if hasattr(response, "stop_reason"):
361
+ span.set_attribute("llm.response.finish_reason", response.stop_reason)
362
+
363
+ return response
364
+
365
+ except Exception as e:
366
+ span.set_error(e)
367
+ raise
368
+
369
+ client.messages.create = wrapped_messages_create
370
+ return client
371
+
372
+ except Exception as e:
373
+ logger.warning(f"Failed to wrap Anthropic client: {e}. Returning unwrapped client.")
374
+ return client
375
+
376
+
377
+ def _wrap_anthropic_async(client: T, capture_content: bool) -> T:
378
+ """Wrap async Anthropic client."""
379
+ try:
380
+ from agentreplay.decorators import ActiveSpan, SpanKind, _current_span
381
+
382
+ original_messages_create = client.messages.create
383
+
384
+ @functools.wraps(original_messages_create)
385
+ async def wrapped_messages_create(*args, **kwargs):
386
+ parent = _current_span.get()
387
+
388
+ span = ActiveSpan(
389
+ name="anthropic.messages.create",
390
+ kind=SpanKind.LLM,
391
+ parent_id=parent.span_id if parent else None,
392
+ trace_id=parent.trace_id if parent else None,
393
+ )
394
+
395
+ model = kwargs.get("model", "claude-3")
396
+ span.set_model(model, provider="anthropic")
397
+ span.set_attribute("llm.request.type", "chat")
398
+
399
+ if capture_content:
400
+ messages = kwargs.get("messages", [])
401
+ system = kwargs.get("system")
402
+ input_data = {"messages": messages}
403
+ if system:
404
+ input_data["system"] = system
405
+ span.set_input(input_data)
406
+
407
+ with span:
408
+ try:
409
+ response = await original_messages_create(*args, **kwargs)
410
+
411
+ if capture_content and hasattr(response, "content") and response.content:
412
+ content = response.content[0]
413
+ if hasattr(content, "text"):
414
+ span.set_output({"content": content.text})
415
+
416
+ if hasattr(response, "usage"):
417
+ span.set_token_usage(
418
+ prompt_tokens=response.usage.input_tokens,
419
+ completion_tokens=response.usage.output_tokens,
420
+ )
421
+
422
+ if hasattr(response, "stop_reason"):
423
+ span.set_attribute("llm.response.finish_reason", response.stop_reason)
424
+
425
+ return response
426
+
427
+ except Exception as e:
428
+ span.set_error(e)
429
+ raise
430
+
431
+ client.messages.create = wrapped_messages_create
432
+ return client
433
+
434
+ except Exception as e:
435
+ logger.warning(f"Failed to wrap async Anthropic client: {e}")
436
+ return client
437
+
438
+
439
+ # =============================================================================
440
+ # Generic Wrapper
441
+ # =============================================================================
442
+
443
+ def wrap_method(
444
+ obj: Any,
445
+ method_name: str,
446
+ *,
447
+ span_name: Optional[str] = None,
448
+ kind: str = "chain",
449
+ capture_input: bool = True,
450
+ capture_output: bool = True,
451
+ ) -> None:
452
+ """Wrap a specific method on an object for tracing.
453
+
454
+ Low-level utility for custom instrumentation.
455
+
456
+ Args:
457
+ obj: Object containing the method
458
+ method_name: Name of the method to wrap
459
+ span_name: Name for the span (default: method name)
460
+ kind: Span kind
461
+ capture_input: Whether to capture method args
462
+ capture_output: Whether to capture return value
463
+
464
+ Example:
465
+ >>> wrap_method(my_service, "call_api", span_name="api.call", kind="http")
466
+ """
467
+ from agentreplay.decorators import ActiveSpan, _current_span
468
+ import inspect
469
+
470
+ original_method = getattr(obj, method_name)
471
+ name = span_name or method_name
472
+
473
+ if inspect.iscoroutinefunction(original_method):
474
+ @functools.wraps(original_method)
475
+ async def async_wrapped(*args, **kwargs):
476
+ parent = _current_span.get()
477
+ span = ActiveSpan(
478
+ name=name,
479
+ kind=kind,
480
+ parent_id=parent.span_id if parent else None,
481
+ trace_id=parent.trace_id if parent else None,
482
+ )
483
+
484
+ if capture_input:
485
+ span.set_input({"args": args, "kwargs": kwargs})
486
+
487
+ with span:
488
+ try:
489
+ result = await original_method(*args, **kwargs)
490
+ if capture_output:
491
+ span.set_output(result)
492
+ return result
493
+ except Exception as e:
494
+ span.set_error(e)
495
+ raise
496
+
497
+ setattr(obj, method_name, async_wrapped)
498
+ else:
499
+ @functools.wraps(original_method)
500
+ def sync_wrapped(*args, **kwargs):
501
+ parent = _current_span.get()
502
+ span = ActiveSpan(
503
+ name=name,
504
+ kind=kind,
505
+ parent_id=parent.span_id if parent else None,
506
+ trace_id=parent.trace_id if parent else None,
507
+ )
508
+
509
+ if capture_input:
510
+ span.set_input({"args": args, "kwargs": kwargs})
511
+
512
+ with span:
513
+ try:
514
+ result = original_method(*args, **kwargs)
515
+ if capture_output:
516
+ span.set_output(result)
517
+ return result
518
+ except Exception as e:
519
+ span.set_error(e)
520
+ raise
521
+
522
+ setattr(obj, method_name, sync_wrapped)