agentreplay 0.1.2__py3-none-any.whl → 0.4.2__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,541 @@
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
+ Decorator-based tracing for Agentreplay.
17
+
18
+ Provides @traceable and @observe decorators for easy function instrumentation.
19
+
20
+ Example:
21
+ >>> from agentreplay import init, traceable
22
+ >>>
23
+ >>> init()
24
+ >>>
25
+ >>> @traceable
26
+ >>> def my_function(query: str) -> str:
27
+ ... return f"Result for {query}"
28
+ >>>
29
+ >>> result = my_function("hello") # Automatically traced!
30
+ """
31
+
32
+ import functools
33
+ import inspect
34
+ import time
35
+ import logging
36
+ from typing import (
37
+ Optional, Callable, TypeVar, Any, Dict, Union,
38
+ overload, ParamSpec, Awaitable
39
+ )
40
+ from contextvars import ContextVar
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Type variables for generic decorators
45
+ P = ParamSpec("P")
46
+ R = TypeVar("R")
47
+
48
+ # Context variable for current span
49
+ _current_span: ContextVar[Optional[Any]] = ContextVar("current_span", default=None)
50
+
51
+
52
+ # =============================================================================
53
+ # Span Kind
54
+ # =============================================================================
55
+
56
+ class SpanKind:
57
+ """Span kind constants for categorizing operations."""
58
+ CHAIN = "chain"
59
+ LLM = "llm"
60
+ TOOL = "tool"
61
+ RETRIEVER = "retriever"
62
+ EMBEDDING = "embedding"
63
+ GUARDRAIL = "guardrail"
64
+ CACHE = "cache"
65
+ HTTP = "http"
66
+ DB = "db"
67
+
68
+
69
+ # =============================================================================
70
+ # Active Span
71
+ # =============================================================================
72
+
73
+ class ActiveSpan:
74
+ """Active span with methods to add data.
75
+
76
+ This is yielded by the trace() context manager and passed to
77
+ decorated functions.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ name: str,
83
+ kind: str = SpanKind.CHAIN,
84
+ span_id: Optional[str] = None,
85
+ parent_id: Optional[str] = None,
86
+ trace_id: Optional[str] = None,
87
+ ):
88
+ self.name = name
89
+ self.kind = kind
90
+ self.span_id = span_id or self._generate_id()
91
+ self.parent_id = parent_id
92
+ self.trace_id = trace_id or self._generate_id()
93
+ self.start_time = time.time()
94
+ self.end_time: Optional[float] = None
95
+ self.attributes: Dict[str, Any] = {}
96
+ self.events: list = []
97
+ self.input_data: Optional[Any] = None
98
+ self.output_data: Optional[Any] = None
99
+ self.error: Optional[Exception] = None
100
+ self.token_usage: Dict[str, int] = {}
101
+ self._ended = False
102
+
103
+ @staticmethod
104
+ def _generate_id() -> str:
105
+ """Generate unique span ID."""
106
+ import uuid
107
+ return uuid.uuid4().hex[:16]
108
+
109
+ def set_input(self, data: Any) -> "ActiveSpan":
110
+ """Set input data."""
111
+ self.input_data = data
112
+ return self
113
+
114
+ def set_output(self, data: Any) -> "ActiveSpan":
115
+ """Set output data."""
116
+ self.output_data = data
117
+ return self
118
+
119
+ def set_attribute(self, key: str, value: Any) -> "ActiveSpan":
120
+ """Set a span attribute."""
121
+ self.attributes[key] = value
122
+ return self
123
+
124
+ def set_attributes(self, attributes: Dict[str, Any]) -> "ActiveSpan":
125
+ """Set multiple attributes."""
126
+ self.attributes.update(attributes)
127
+ return self
128
+
129
+ def add_event(self, name: str, attributes: Optional[Dict[str, Any]] = None) -> "ActiveSpan":
130
+ """Add an event to the span."""
131
+ self.events.append({
132
+ "name": name,
133
+ "timestamp": time.time(),
134
+ "attributes": attributes or {},
135
+ })
136
+ return self
137
+
138
+ def set_error(self, error: Exception) -> "ActiveSpan":
139
+ """Set error on span."""
140
+ self.error = error
141
+ self.attributes["error.type"] = type(error).__name__
142
+ self.attributes["error.message"] = str(error)
143
+ import traceback
144
+ self.attributes["error.stack"] = traceback.format_exc()
145
+ return self
146
+
147
+ def set_token_usage(
148
+ self,
149
+ prompt_tokens: Optional[int] = None,
150
+ completion_tokens: Optional[int] = None,
151
+ total_tokens: Optional[int] = None,
152
+ ) -> "ActiveSpan":
153
+ """Set token usage for LLM calls."""
154
+ if prompt_tokens is not None:
155
+ self.token_usage["prompt"] = prompt_tokens
156
+ self.attributes["gen_ai.usage.prompt_tokens"] = prompt_tokens
157
+ if completion_tokens is not None:
158
+ self.token_usage["completion"] = completion_tokens
159
+ self.attributes["gen_ai.usage.completion_tokens"] = completion_tokens
160
+ if total_tokens is not None:
161
+ self.token_usage["total"] = total_tokens
162
+ self.attributes["gen_ai.usage.total_tokens"] = total_tokens
163
+ return self
164
+
165
+ def set_model(self, model: str, provider: Optional[str] = None) -> "ActiveSpan":
166
+ """Set model information."""
167
+ self.attributes["gen_ai.request.model"] = model
168
+ if provider:
169
+ self.attributes["gen_ai.system"] = provider
170
+ return self
171
+
172
+ def end(self) -> None:
173
+ """End the span and send to backend."""
174
+ if self._ended:
175
+ return
176
+
177
+ self._ended = True
178
+ self.end_time = time.time()
179
+
180
+ # Send to backend
181
+ self._send()
182
+
183
+ def _send(self) -> None:
184
+ """Send span to Agentreplay backend."""
185
+ try:
186
+ from agentreplay.sdk import get_batching_client, is_initialized, get_config
187
+
188
+ if not is_initialized():
189
+ return
190
+
191
+ config = get_config()
192
+ if not config.enabled:
193
+ return
194
+
195
+ # Build edge
196
+ from agentreplay.models import AgentFlowEdge, SpanType
197
+
198
+ # Map kind to SpanType
199
+ span_type_map = {
200
+ SpanKind.CHAIN: SpanType.ROOT,
201
+ SpanKind.LLM: SpanType.TOOL_CALL,
202
+ SpanKind.TOOL: SpanType.TOOL_CALL,
203
+ SpanKind.RETRIEVER: SpanType.TOOL_CALL,
204
+ SpanKind.EMBEDDING: SpanType.TOOL_CALL,
205
+ }
206
+ span_type = span_type_map.get(self.kind, SpanType.ROOT)
207
+
208
+ # Calculate duration
209
+ duration_us = int((self.end_time - self.start_time) * 1_000_000) if self.end_time else 0
210
+
211
+ # Build payload
212
+ payload = {}
213
+ if self.input_data is not None and config.capture_input:
214
+ payload["input"] = self._safe_serialize(self.input_data)
215
+ if self.output_data is not None and config.capture_output:
216
+ payload["output"] = self._safe_serialize(self.output_data)
217
+ if self.attributes:
218
+ payload["attributes"] = self.attributes
219
+ if self.events:
220
+ payload["events"] = self.events
221
+ if self.error:
222
+ payload["error"] = {
223
+ "type": type(self.error).__name__,
224
+ "message": str(self.error),
225
+ }
226
+
227
+ edge = AgentFlowEdge(
228
+ tenant_id=config.tenant_id,
229
+ project_id=config.project_id,
230
+ agent_id=config.agent_id,
231
+ session_id=int(self.trace_id[:8], 16) if self.trace_id else 0,
232
+ span_type=span_type,
233
+ timestamp_us=int(self.start_time * 1_000_000),
234
+ duration_us=duration_us,
235
+ token_count=self.token_usage.get("total", 0),
236
+ payload=payload,
237
+ )
238
+
239
+ # Send via batching client
240
+ client = get_batching_client()
241
+ client.insert(edge)
242
+
243
+ except Exception as e:
244
+ logger.debug(f"Failed to send span: {e}")
245
+
246
+ def _safe_serialize(self, data: Any, max_size: int = 10000) -> Any:
247
+ """Safely serialize data with size limits."""
248
+ import json
249
+
250
+ try:
251
+ serialized = json.dumps(data, default=str)
252
+ if len(serialized) > max_size:
253
+ return {"__truncated": True, "__preview": serialized[:1000]}
254
+ return data
255
+ except Exception:
256
+ return str(data)[:max_size]
257
+
258
+ def __enter__(self) -> "ActiveSpan":
259
+ """Context manager entry."""
260
+ # Set as current span
261
+ self._token = _current_span.set(self)
262
+ return self
263
+
264
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
265
+ """Context manager exit."""
266
+ # Capture error if any
267
+ if exc_val is not None:
268
+ self.set_error(exc_val)
269
+
270
+ # End span
271
+ self.end()
272
+
273
+ # Reset current span
274
+ _current_span.reset(self._token)
275
+
276
+
277
+ # =============================================================================
278
+ # Get Current Span
279
+ # =============================================================================
280
+
281
+ def get_current_span() -> Optional[ActiveSpan]:
282
+ """Get the currently active span.
283
+
284
+ Returns:
285
+ ActiveSpan if inside a traced context, None otherwise
286
+ """
287
+ return _current_span.get()
288
+
289
+
290
+ # =============================================================================
291
+ # Traceable Decorator
292
+ # =============================================================================
293
+
294
+ @overload
295
+ def traceable(func: Callable[P, R]) -> Callable[P, R]: ...
296
+
297
+ @overload
298
+ def traceable(
299
+ *,
300
+ name: Optional[str] = None,
301
+ kind: str = SpanKind.CHAIN,
302
+ capture_input: bool = True,
303
+ capture_output: bool = True,
304
+ metadata: Optional[Dict[str, Any]] = None,
305
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
306
+
307
+
308
+ def traceable(
309
+ func: Optional[Callable[P, R]] = None,
310
+ *,
311
+ name: Optional[str] = None,
312
+ kind: str = SpanKind.CHAIN,
313
+ capture_input: bool = True,
314
+ capture_output: bool = True,
315
+ metadata: Optional[Dict[str, Any]] = None,
316
+ ) -> Union[Callable[P, R], Callable[[Callable[P, R]], Callable[P, R]]]:
317
+ """Decorator to trace a function.
318
+
319
+ Works with both sync and async functions. Automatically captures
320
+ inputs, outputs, errors, and timing.
321
+
322
+ Args:
323
+ func: Function to decorate (when used without parentheses)
324
+ name: Span name (default: function name)
325
+ kind: Span kind (chain, llm, tool, retriever, etc.)
326
+ capture_input: Whether to capture function inputs
327
+ capture_output: Whether to capture function output
328
+ metadata: Additional metadata to attach
329
+
330
+ Returns:
331
+ Decorated function
332
+
333
+ Example:
334
+ >>> @traceable
335
+ >>> def simple_function():
336
+ ... return "hello"
337
+
338
+ >>> @traceable(name="my_operation", kind="tool")
339
+ >>> def tool_function(query: str):
340
+ ... return search(query)
341
+
342
+ >>> @traceable(capture_input=False) # Don't capture sensitive inputs
343
+ >>> def sensitive_function(password: str):
344
+ ... return authenticate(password)
345
+ """
346
+ def decorator(fn: Callable[P, R]) -> Callable[P, R]:
347
+ span_name = name or fn.__name__
348
+
349
+ # Check if async
350
+ if inspect.iscoroutinefunction(fn):
351
+ @functools.wraps(fn)
352
+ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
353
+ # Get parent span
354
+ parent = get_current_span()
355
+
356
+ # Create span
357
+ span = ActiveSpan(
358
+ name=span_name,
359
+ kind=kind,
360
+ parent_id=parent.span_id if parent else None,
361
+ trace_id=parent.trace_id if parent else None,
362
+ )
363
+
364
+ # Add metadata
365
+ if metadata:
366
+ span.set_attributes(metadata)
367
+
368
+ # Capture input
369
+ if capture_input:
370
+ try:
371
+ input_data = _capture_args(fn, args, kwargs)
372
+ span.set_input(input_data)
373
+ except Exception:
374
+ pass
375
+
376
+ # Execute with span context
377
+ with span:
378
+ try:
379
+ result = await fn(*args, **kwargs)
380
+
381
+ # Capture output
382
+ if capture_output:
383
+ span.set_output(result)
384
+
385
+ return result
386
+ except Exception as e:
387
+ span.set_error(e)
388
+ raise
389
+
390
+ return async_wrapper
391
+ else:
392
+ @functools.wraps(fn)
393
+ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
394
+ # Get parent span
395
+ parent = get_current_span()
396
+
397
+ # Create span
398
+ span = ActiveSpan(
399
+ name=span_name,
400
+ kind=kind,
401
+ parent_id=parent.span_id if parent else None,
402
+ trace_id=parent.trace_id if parent else None,
403
+ )
404
+
405
+ # Add metadata
406
+ if metadata:
407
+ span.set_attributes(metadata)
408
+
409
+ # Capture input
410
+ if capture_input:
411
+ try:
412
+ input_data = _capture_args(fn, args, kwargs)
413
+ span.set_input(input_data)
414
+ except Exception:
415
+ pass
416
+
417
+ # Execute with span context
418
+ with span:
419
+ try:
420
+ result = fn(*args, **kwargs)
421
+
422
+ # Capture output
423
+ if capture_output:
424
+ span.set_output(result)
425
+
426
+ return result
427
+ except Exception as e:
428
+ span.set_error(e)
429
+ raise
430
+
431
+ return sync_wrapper
432
+
433
+ # Handle @traceable vs @traceable()
434
+ if func is not None:
435
+ return decorator(func)
436
+ return decorator
437
+
438
+
439
+ # Alias for Langfuse-style API
440
+ observe = traceable
441
+
442
+
443
+ def _capture_args(fn: Callable, args: tuple, kwargs: dict) -> Dict[str, Any]:
444
+ """Capture function arguments as a dict."""
445
+ sig = inspect.signature(fn)
446
+ params = list(sig.parameters.keys())
447
+
448
+ result = {}
449
+ for i, arg in enumerate(args):
450
+ if i < len(params):
451
+ result[params[i]] = arg
452
+ else:
453
+ result[f"arg_{i}"] = arg
454
+
455
+ result.update(kwargs)
456
+ return result
457
+
458
+
459
+ # =============================================================================
460
+ # Trace Context Manager
461
+ # =============================================================================
462
+
463
+ def trace(
464
+ name: str,
465
+ *,
466
+ kind: str = SpanKind.CHAIN,
467
+ input: Optional[Any] = None,
468
+ metadata: Optional[Dict[str, Any]] = None,
469
+ ) -> ActiveSpan:
470
+ """Create a trace span as a context manager.
471
+
472
+ Args:
473
+ name: Span name
474
+ kind: Span kind (chain, llm, tool, retriever, etc.)
475
+ input: Input data to record
476
+ metadata: Additional metadata
477
+
478
+ Returns:
479
+ ActiveSpan context manager
480
+
481
+ Example:
482
+ >>> with trace("retrieve_documents", kind="retriever") as span:
483
+ ... docs = vector_db.search(query)
484
+ ... span.set_output({"count": len(docs)})
485
+ ... return docs
486
+ """
487
+ # Get parent span
488
+ parent = get_current_span()
489
+
490
+ # Create span
491
+ span = ActiveSpan(
492
+ name=name,
493
+ kind=kind,
494
+ parent_id=parent.span_id if parent else None,
495
+ trace_id=parent.trace_id if parent else None,
496
+ )
497
+
498
+ # Set input
499
+ if input is not None:
500
+ span.set_input(input)
501
+
502
+ # Set metadata
503
+ if metadata:
504
+ span.set_attributes(metadata)
505
+
506
+ return span
507
+
508
+
509
+ def start_span(
510
+ name: str,
511
+ *,
512
+ kind: str = SpanKind.CHAIN,
513
+ input: Optional[Any] = None,
514
+ metadata: Optional[Dict[str, Any]] = None,
515
+ ) -> ActiveSpan:
516
+ """Start a manual span (must call span.end()).
517
+
518
+ Use trace() context manager when possible. This is for cases
519
+ where you need manual control over span lifetime.
520
+
521
+ Args:
522
+ name: Span name
523
+ kind: Span kind
524
+ input: Input data
525
+ metadata: Additional metadata
526
+
527
+ Returns:
528
+ ActiveSpan (call .end() when done)
529
+
530
+ Example:
531
+ >>> span = start_span("long_operation", kind="tool")
532
+ >>> try:
533
+ ... result = do_something()
534
+ ... span.set_output(result)
535
+ >>> except Exception as e:
536
+ ... span.set_error(e)
537
+ ... raise
538
+ >>> finally:
539
+ ... span.end()
540
+ """
541
+ return trace(name, kind=kind, input=input, metadata=metadata)
@@ -50,8 +50,12 @@ def get_site_packages():
50
50
  import agentreplay
51
51
  agentreplay_dir = os.path.dirname(agentreplay.__file__)
52
52
  site_packages = os.path.dirname(agentreplay_dir)
53
- if site_packages not in paths:
54
- paths.insert(0, site_packages)
53
+
54
+ # Prioritize the directory where agentreplay is actually installed
55
+ if site_packages in paths:
56
+ paths.remove(site_packages)
57
+ paths.insert(0, site_packages)
58
+
55
59
  except ImportError:
56
60
  pass
57
61