agentreplay 0.1.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.
agentreplay/unified.py ADDED
@@ -0,0 +1,465 @@
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
+ """Unified observability interface for vendor-agnostic tracing.
16
+
17
+ This module provides a single API that works with multiple observability
18
+ backends (Agentreplay, LangSmith, Langfuse, Opik) based on environment variables.
19
+
20
+ This allows users to:
21
+ - Switch vendors without code changes
22
+ - A/B test different observability tools
23
+ - Send traces to multiple backends simultaneously
24
+
25
+ Example:
26
+ # Set environment variable to choose backend
27
+ export OBSERVABILITY_BACKEND="agentreplay" # or "langsmith", "langfuse", "opik"
28
+
29
+ # Python code - works with any backend!
30
+ from agentreplay.unified import UnifiedObservability
31
+
32
+ obs = UnifiedObservability()
33
+
34
+ with obs.trace("operation") as span:
35
+ span.set_attribute("key", "value")
36
+ result = do_work()
37
+ span.set_output(result)
38
+ """
39
+
40
+ import os
41
+ import logging
42
+ from typing import Optional, Dict, Any, ContextManager
43
+ from contextlib import contextmanager
44
+ from abc import ABC, abstractmethod
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ class ObservabilityBackend(ABC):
50
+ """Abstract interface for observability backends."""
51
+
52
+ @abstractmethod
53
+ def trace(self, name: str, **kwargs) -> ContextManager:
54
+ """Create a trace span.
55
+
56
+ Args:
57
+ name: Span name
58
+ **kwargs: Backend-specific parameters
59
+
60
+ Returns:
61
+ Context manager for span
62
+ """
63
+ pass
64
+
65
+ @abstractmethod
66
+ def set_attribute(self, span: Any, key: str, value: Any) -> None:
67
+ """Set attribute on span.
68
+
69
+ Args:
70
+ span: Span object
71
+ key: Attribute key
72
+ value: Attribute value
73
+ """
74
+ pass
75
+
76
+ @abstractmethod
77
+ def close(self) -> None:
78
+ """Close backend and flush any buffered data."""
79
+ pass
80
+
81
+
82
+ class AgentreplayBackend(ObservabilityBackend):
83
+ """Agentreplay observability backend."""
84
+
85
+ def __init__(self, **config):
86
+ """Initialize Agentreplay backend.
87
+
88
+ Args:
89
+ **config: Configuration passed to AgentreplayClient
90
+ """
91
+ from agentreplay import AgentreplayClient
92
+
93
+ url = config.get("url", os.getenv("AGENTREPLAY_URL", "http://localhost:8080"))
94
+ tenant_id = config.get("tenant_id", int(os.getenv("AGENTREPLAY_TENANT_ID", "1")))
95
+ project_id = config.get("project_id", int(os.getenv("AGENTREPLAY_PROJECT_ID", "0")))
96
+
97
+ self.client = AgentreplayClient(
98
+ url=url,
99
+ tenant_id=tenant_id,
100
+ project_id=project_id,
101
+ )
102
+
103
+ logger.info("AgentreplayBackend initialized")
104
+
105
+ @contextmanager
106
+ def trace(self, name: str, **kwargs):
107
+ """Create Agentreplay span."""
108
+ span = self.client.trace(**kwargs)
109
+ span_ctx = span.__enter__()
110
+ try:
111
+ yield span_ctx
112
+ finally:
113
+ span.__exit__(None, None, None)
114
+
115
+ def set_attribute(self, span: Any, key: str, value: Any) -> None:
116
+ """Set attribute on Agentreplay span."""
117
+ # Agentreplay uses specific methods for common attributes
118
+ if key == "token_count" and hasattr(span, "set_token_count"):
119
+ span.set_token_count(int(value))
120
+ elif key == "confidence" and hasattr(span, "set_confidence"):
121
+ span.set_confidence(float(value))
122
+ # Other attributes stored in payload
123
+
124
+ def close(self) -> None:
125
+ """Close Agentreplay client."""
126
+ self.client.close()
127
+
128
+
129
+ class LangSmithBackend(ObservabilityBackend):
130
+ """LangSmith observability backend."""
131
+
132
+ def __init__(self, **config):
133
+ """Initialize LangSmith backend.
134
+
135
+ Args:
136
+ **config: Configuration for LangSmith
137
+ """
138
+ try:
139
+ from langsmith import Client
140
+
141
+ api_key = config.get("api_key", os.getenv("LANGCHAIN_API_KEY"))
142
+ if not api_key:
143
+ raise ValueError("LANGCHAIN_API_KEY environment variable required for LangSmith")
144
+
145
+ self.client = Client(api_key=api_key)
146
+ logger.info("LangSmithBackend initialized")
147
+
148
+ except ImportError:
149
+ raise ImportError(
150
+ "LangSmith not installed. Install with: pip install langsmith"
151
+ )
152
+
153
+ @contextmanager
154
+ def trace(self, name: str, **kwargs):
155
+ """Create LangSmith span."""
156
+ from langsmith import traceable
157
+
158
+ # Use LangSmith's traceable decorator as context manager
159
+ with traceable(name=name, **kwargs) as span:
160
+ yield span
161
+
162
+ def set_attribute(self, span: Any, key: str, value: Any) -> None:
163
+ """Set attribute on LangSmith span."""
164
+ if hasattr(span, "metadata"):
165
+ span.metadata[key] = value
166
+
167
+ def close(self) -> None:
168
+ """Close LangSmith client."""
169
+ pass # LangSmith handles cleanup automatically
170
+
171
+
172
+ class LangfuseBackend(ObservabilityBackend):
173
+ """Langfuse observability backend."""
174
+
175
+ def __init__(self, **config):
176
+ """Initialize Langfuse backend.
177
+
178
+ Args:
179
+ **config: Configuration for Langfuse
180
+ """
181
+ try:
182
+ from langfuse import Langfuse
183
+
184
+ public_key = config.get("public_key", os.getenv("LANGFUSE_PUBLIC_KEY"))
185
+ secret_key = config.get("secret_key", os.getenv("LANGFUSE_SECRET_KEY"))
186
+ host = config.get("host", os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com"))
187
+
188
+ if not public_key or not secret_key:
189
+ raise ValueError(
190
+ "LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY required for Langfuse"
191
+ )
192
+
193
+ self.client = Langfuse(
194
+ public_key=public_key,
195
+ secret_key=secret_key,
196
+ host=host,
197
+ )
198
+ logger.info("LangfuseBackend initialized")
199
+
200
+ except ImportError:
201
+ raise ImportError(
202
+ "Langfuse not installed. Install with: pip install langfuse"
203
+ )
204
+
205
+ @contextmanager
206
+ def trace(self, name: str, **kwargs):
207
+ """Create Langfuse span."""
208
+ trace = self.client.trace(name=name, **kwargs)
209
+ try:
210
+ yield trace
211
+ finally:
212
+ self.client.flush()
213
+
214
+ def set_attribute(self, span: Any, key: str, value: Any) -> None:
215
+ """Set attribute on Langfuse span."""
216
+ if hasattr(span, "update"):
217
+ span.update(metadata={key: value})
218
+
219
+ def close(self) -> None:
220
+ """Close Langfuse client."""
221
+ self.client.flush()
222
+
223
+
224
+ class OpikBackend(ObservabilityBackend):
225
+ """Opik observability backend."""
226
+
227
+ def __init__(self, **config):
228
+ """Initialize Opik backend.
229
+
230
+ Args:
231
+ **config: Configuration for Opik
232
+ """
233
+ try:
234
+ import opik
235
+
236
+ api_key = config.get("api_key", os.getenv("OPIK_API_KEY"))
237
+ workspace = config.get("workspace", os.getenv("OPIK_WORKSPACE"))
238
+
239
+ if api_key:
240
+ opik.configure(api_key=api_key, workspace=workspace)
241
+
242
+ self.client = opik.Opik()
243
+ logger.info("OpikBackend initialized")
244
+
245
+ except ImportError:
246
+ raise ImportError(
247
+ "Opik not installed. Install with: pip install opik"
248
+ )
249
+
250
+ @contextmanager
251
+ def trace(self, name: str, **kwargs):
252
+ """Create Opik span."""
253
+ trace = self.client.trace(name=name, **kwargs)
254
+ try:
255
+ yield trace
256
+ finally:
257
+ trace.end()
258
+
259
+ def set_attribute(self, span: Any, key: str, value: Any) -> None:
260
+ """Set attribute on Opik span."""
261
+ if hasattr(span, "log_metadata"):
262
+ span.log_metadata(key, value)
263
+
264
+ def close(self) -> None:
265
+ """Close Opik client."""
266
+ self.client.flush()
267
+
268
+
269
+ class UnifiedObservability:
270
+ """Unified interface for multiple observability backends.
271
+
272
+ Automatically selects backend based on environment variables:
273
+ - OBSERVABILITY_BACKEND: Backend name (agentreplay, langsmith, langfuse, opik)
274
+ - Or checks for API keys: LANGCHAIN_API_KEY, LANGFUSE_PUBLIC_KEY, OPIK_API_KEY
275
+
276
+ Example:
277
+ >>> from agentreplay.unified import UnifiedObservability
278
+ >>>
279
+ >>> # Auto-detects backend from environment
280
+ >>> obs = UnifiedObservability()
281
+ >>>
282
+ >>> with obs.trace("my_operation") as span:
283
+ ... span.set_attribute("key", "value")
284
+ ... result = do_work()
285
+ """
286
+
287
+ def __init__(self, backend_name: Optional[str] = None, **config):
288
+ """Initialize unified observability.
289
+
290
+ Args:
291
+ backend_name: Override backend selection (agentreplay, langsmith, langfuse, opik)
292
+ **config: Backend-specific configuration
293
+ """
294
+ # Determine backend
295
+ if backend_name:
296
+ backend = backend_name.lower()
297
+ else:
298
+ backend = self._auto_detect_backend()
299
+
300
+ # Create backend
301
+ self.backend_name = backend
302
+ self.backend = self._create_backend(backend, **config)
303
+
304
+ logger.info(f"UnifiedObservability initialized with backend: {backend}")
305
+
306
+ def _auto_detect_backend(self) -> str:
307
+ """Auto-detect backend from environment variables.
308
+
309
+ Returns:
310
+ Backend name
311
+ """
312
+ # Check explicit setting
313
+ backend = os.getenv("OBSERVABILITY_BACKEND", "").lower()
314
+ if backend in ("agentreplay", "langsmith", "langfuse", "opik"):
315
+ return backend
316
+
317
+ # Auto-detect from API keys
318
+ if os.getenv("LANGCHAIN_API_KEY"):
319
+ return "langsmith"
320
+ elif os.getenv("LANGFUSE_PUBLIC_KEY"):
321
+ return "langfuse"
322
+ elif os.getenv("OPIK_API_KEY"):
323
+ return "opik"
324
+ else:
325
+ # Default to Agentreplay
326
+ return "agentreplay"
327
+
328
+ def _create_backend(self, backend_name: str, **config) -> ObservabilityBackend:
329
+ """Create backend instance.
330
+
331
+ Args:
332
+ backend_name: Backend name
333
+ **config: Backend configuration
334
+
335
+ Returns:
336
+ ObservabilityBackend instance
337
+ """
338
+ if backend_name == "agentreplay":
339
+ return AgentreplayBackend(**config)
340
+ elif backend_name == "langsmith":
341
+ return LangSmithBackend(**config)
342
+ elif backend_name == "langfuse":
343
+ return LangfuseBackend(**config)
344
+ elif backend_name == "opik":
345
+ return OpikBackend(**config)
346
+ else:
347
+ raise ValueError(
348
+ f"Unknown backend: {backend_name}. "
349
+ f"Supported: agentreplay, langsmith, langfuse, opik"
350
+ )
351
+
352
+ def trace(self, name: str, **kwargs) -> ContextManager:
353
+ """Create a trace span.
354
+
355
+ Args:
356
+ name: Span name
357
+ **kwargs: Backend-specific parameters
358
+
359
+ Returns:
360
+ Context manager for span
361
+ """
362
+ return self.backend.trace(name, **kwargs)
363
+
364
+ def set_attribute(self, span: Any, key: str, value: Any) -> None:
365
+ """Set attribute on span.
366
+
367
+ Args:
368
+ span: Span object
369
+ key: Attribute key
370
+ value: Attribute value
371
+ """
372
+ self.backend.set_attribute(span, key, value)
373
+
374
+ def close(self) -> None:
375
+ """Close backend and flush buffered data."""
376
+ self.backend.close()
377
+
378
+ def __enter__(self):
379
+ """Context manager entry."""
380
+ return self
381
+
382
+ def __exit__(self, exc_type, exc_val, exc_tb):
383
+ """Context manager exit."""
384
+ self.close()
385
+
386
+
387
+ class MultiBackend:
388
+ """Send traces to multiple backends simultaneously.
389
+
390
+ Example:
391
+ >>> from agentreplay.unified import MultiBackend
392
+ >>>
393
+ >>> # Send to both Agentreplay and LangSmith
394
+ >>> obs = MultiBackend(["agentreplay", "langsmith"])
395
+ >>>
396
+ >>> with obs.trace("operation") as spans:
397
+ ... for span in spans:
398
+ ... span.set_attribute("key", "value")
399
+ """
400
+
401
+ def __init__(self, backend_names: list, **config):
402
+ """Initialize multi-backend.
403
+
404
+ Args:
405
+ backend_names: List of backend names
406
+ **config: Configuration for backends
407
+ """
408
+ self.backends = []
409
+
410
+ for name in backend_names:
411
+ try:
412
+ obs = UnifiedObservability(backend_name=name, **config)
413
+ self.backends.append(obs)
414
+ logger.info(f"Added backend: {name}")
415
+ except Exception as e:
416
+ logger.warning(f"Failed to initialize {name}: {e}")
417
+
418
+ @contextmanager
419
+ def trace(self, name: str, **kwargs):
420
+ """Create spans in all backends.
421
+
422
+ Args:
423
+ name: Span name
424
+ **kwargs: Parameters passed to all backends
425
+
426
+ Yields:
427
+ List of span objects (one per backend)
428
+ """
429
+ contexts = []
430
+ spans = []
431
+
432
+ # Enter all contexts
433
+ for backend_obs in self.backends:
434
+ try:
435
+ ctx = backend_obs.trace(name, **kwargs)
436
+ span = ctx.__enter__()
437
+ contexts.append(ctx)
438
+ spans.append(span)
439
+ except Exception as e:
440
+ logger.error(f"Failed to create span in {backend_obs.backend_name}: {e}")
441
+
442
+ try:
443
+ yield spans
444
+ finally:
445
+ # Exit all contexts
446
+ for ctx in contexts:
447
+ try:
448
+ ctx.__exit__(None, None, None)
449
+ except Exception as e:
450
+ logger.error(f"Error exiting span context: {e}")
451
+
452
+ def close(self) -> None:
453
+ """Close all backends."""
454
+ for backend_obs in self.backends:
455
+ try:
456
+ backend_obs.close()
457
+ except Exception as e:
458
+ logger.error(f"Error closing backend: {e}")
459
+
460
+
461
+ __all__ = [
462
+ "ObservabilityBackend",
463
+ "UnifiedObservability",
464
+ "MultiBackend",
465
+ ]