llmops-observability 8.0.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.
@@ -0,0 +1,641 @@
1
+ """
2
+ Trace Manager for LLMOps Observability
3
+ Handles tracing and tracking of LLM operations with direct Langfuse integration
4
+ Inspired by veriskGO's trace_manager with instant span sending to Langfuse
5
+ """
6
+ from __future__ import annotations
7
+ import uuid
8
+ import threading
9
+ import time
10
+ import traceback
11
+ import functools
12
+ import inspect
13
+ import sys
14
+ import json
15
+ from datetime import datetime, timezone
16
+ from typing import Optional, Dict, Any, List, Union
17
+ from .models import SpanContext
18
+ from .config import get_langfuse_client
19
+
20
+
21
+ # ============================================================
22
+ # Safe Serialization Helpers (from veriskGO)
23
+ # ============================================================
24
+
25
+ # Maximum size for serialized output (200 KB)
26
+ MAX_OUTPUT_SIZE = 200 * 1024 # 200 KB
27
+
28
+ def serialize_value(value: Any, max_size: int = MAX_OUTPUT_SIZE) -> Any:
29
+ """Serialize value with size limit to prevent large data issues.
30
+
31
+ Args:
32
+ value: Value to serialize
33
+ max_size: Maximum size in bytes (default 200 KB)
34
+
35
+ Returns:
36
+ Serialized value, truncated if necessary
37
+ """
38
+ try:
39
+ # First, serialize to JSON
40
+ serialized_str = json.dumps(value, default=str)
41
+ serialized_bytes = serialized_str.encode('utf-8')
42
+
43
+ # Check size
44
+ if len(serialized_bytes) <= max_size:
45
+ return json.loads(serialized_str)
46
+
47
+ # Too large - return truncation info instead
48
+ preview_size = min(1000, max_size // 2) # Show first 1KB as preview
49
+ preview = serialized_str[:preview_size]
50
+
51
+ print(f"[LLMOps-Observability] Output truncated: {len(serialized_bytes)} bytes → {max_size} bytes limit")
52
+
53
+ return {
54
+ "_truncated": True,
55
+ "_original_size_bytes": len(serialized_bytes),
56
+ "_original_size_mb": round(len(serialized_bytes) / (1024 * 1024), 2),
57
+ "_preview": preview + "...",
58
+ "_message": f"Output truncated (original: {round(len(serialized_bytes) / (1024 * 1024), 2)} MB, limit: {round(max_size / 1024, 0)} KB)"
59
+ }
60
+ except Exception as e:
61
+ return str(value)
62
+
63
+
64
+ def safe_locals(d: Dict[str, Any]) -> Dict[str, Any]:
65
+ """Safely serialize local variables"""
66
+ return {k: serialize_value(v) for k, v in d.items() if not k.startswith("_")}
67
+
68
+
69
+ # ============================================================
70
+ # Core Trace Manager
71
+ # ============================================================
72
+
73
+ class TraceManager:
74
+ _lock = threading.Lock()
75
+ _active: Dict[str, Any] = {
76
+ "trace_id": None,
77
+ "trace_obj": None, # Langfuse trace object reference
78
+ "spans": [],
79
+ "stack": [], # Stack of active spans for nesting
80
+ "metadata": {},
81
+ "observation_stack": [], # Stack of Langfuse observation contexts
82
+ }
83
+
84
+ @classmethod
85
+ def has_active_trace(cls) -> bool:
86
+ """Check if there's an active trace"""
87
+ return cls._active["trace_id"] is not None
88
+
89
+ @classmethod
90
+ def _id(cls) -> str:
91
+ """Generate a unique ID compatible with Langfuse (32 lowercase hex chars)"""
92
+ return uuid.uuid4().hex
93
+
94
+ @classmethod
95
+ def _now(cls) -> str:
96
+ """Get current timestamp in ISO format"""
97
+ return datetime.now(timezone.utc).isoformat()
98
+
99
+ @classmethod
100
+ def start_trace(
101
+ cls,
102
+ name: str,
103
+ metadata: Optional[Dict[str, Any]] = None,
104
+ user_id: Optional[str] = None,
105
+ session_id: Optional[str] = None,
106
+ tags: Optional[List[str]] = None,
107
+ ) -> str:
108
+ """
109
+ Start a new trace (similar to veriskGO API).
110
+
111
+ Args:
112
+ name: Name of the trace
113
+ metadata: Optional metadata dictionary
114
+ user_id: Optional user ID
115
+ session_id: Optional session ID
116
+ tags: Optional list of tags
117
+
118
+ Returns:
119
+ str: Trace ID
120
+ """
121
+ with cls._lock:
122
+ if cls._active["trace_id"]:
123
+ print("[LLMOps-Observability] WARNING: Trace already active. Ending previous trace.")
124
+ cls._end_trace_internal()
125
+
126
+ trace_id = cls._id()
127
+
128
+ # Store trace information
129
+ cls._active["trace_id"] = trace_id
130
+ cls._active["trace_obj"] = {
131
+ "name": name,
132
+ "user_id": user_id,
133
+ "session_id": session_id,
134
+ "metadata": metadata or {},
135
+ "tags": tags or [],
136
+ "trace_input": None,
137
+ "trace_output": None,
138
+ }
139
+ cls._active["spans"] = []
140
+ cls._active["stack"] = [] # Initialize stack for nested spans
141
+ cls._active["metadata"] = metadata or {}
142
+
143
+ print(f"[LLMOps-Observability] Trace started: {name} (ID: {trace_id})")
144
+ return trace_id
145
+
146
+ @classmethod
147
+ def _end_trace_internal(cls):
148
+ """Internal method to end trace without lock (already within lock context)"""
149
+ trace_id = cls._active["trace_id"]
150
+
151
+ if trace_id:
152
+ # Flush to Langfuse immediately
153
+ langfuse = get_langfuse_client()
154
+ langfuse.flush()
155
+
156
+ # Clear the active trace
157
+ cls._active["trace_id"] = None
158
+ cls._active["trace_obj"] = None
159
+ cls._active["spans"] = []
160
+ cls._active["stack"] = []
161
+ cls._active["metadata"] = {}
162
+ cls._active["observation_stack"] = []
163
+
164
+ @classmethod
165
+ def end_trace(cls, final_output: Optional[Any] = None) -> Optional[str]:
166
+ """
167
+ End the current trace and flush to Langfuse.
168
+
169
+ Args:
170
+ final_output: Optional final output for the trace
171
+
172
+ Returns:
173
+ Optional[str]: Trace ID if successful
174
+ """
175
+ with cls._lock:
176
+ trace_id = cls._active["trace_id"]
177
+
178
+ if not trace_id:
179
+ return None
180
+
181
+ if final_output and cls._active["trace_obj"]:
182
+ cls._active["trace_obj"]["trace_output"] = final_output
183
+
184
+ cls._end_trace_internal()
185
+
186
+ print(f"[LLMOps-Observability] Trace ended and flushed: {trace_id}")
187
+ return trace_id
188
+
189
+ @classmethod
190
+ def finalize_and_send(
191
+ cls,
192
+ *,
193
+ user_id: str,
194
+ session_id: str,
195
+ trace_name: str,
196
+ trace_input: dict,
197
+ trace_output: dict,
198
+ extra_spans: list = [],
199
+ ):
200
+ """
201
+ Finalize and send the trace with input/output (compatible with veriskGO API).
202
+
203
+ This method provides compatibility with veriskGO's API by accepting
204
+ trace_input and trace_output, then ending the trace.
205
+
206
+ Args:
207
+ user_id: User ID for the trace
208
+ session_id: Session ID for the trace
209
+ trace_name: Name of the trace
210
+ trace_input: Input data for the trace
211
+ trace_output: Output data for the trace
212
+ extra_spans: Additional spans (not used in this implementation)
213
+
214
+ Returns:
215
+ bool: True if successful, False otherwise
216
+ """
217
+ with cls._lock:
218
+ trace_id = cls._active.get("trace_id")
219
+ trace_obj = cls._active.get("trace_obj")
220
+
221
+ if not trace_id or not trace_obj:
222
+ print("[LLMOps-Observability] ERROR: No active trace to finalize.")
223
+ return False
224
+
225
+ # Update trace object with input/output
226
+ trace_obj["trace_input"] = serialize_value(trace_input)
227
+ trace_obj["trace_output"] = serialize_value(trace_output)
228
+ trace_obj["user_id"] = user_id
229
+ trace_obj["session_id"] = session_id
230
+ trace_obj["name"] = trace_name
231
+
232
+ # Store updated trace_obj
233
+ cls._active["trace_obj"] = trace_obj
234
+
235
+ # Send trace-level data to Langfuse using the correct API
236
+ try:
237
+ langfuse = get_langfuse_client()
238
+
239
+ # Create trace-level observation using start_as_current_observation
240
+ with langfuse.start_as_current_observation(
241
+ as_type="span",
242
+ name=trace_name,
243
+ trace_context={"trace_id": trace_id},
244
+ input=trace_obj["trace_input"],
245
+ output=trace_obj["trace_output"],
246
+ ) as root:
247
+ # Update trace with user_id, session_id, and metadata
248
+ root.update_trace(
249
+ name=trace_name,
250
+ user_id=user_id,
251
+ session_id=session_id,
252
+ metadata=trace_obj.get("metadata", {}),
253
+ input=trace_obj["trace_input"],
254
+ output=trace_obj["trace_output"],
255
+ )
256
+
257
+ # Flush immediately (no batching)
258
+ langfuse.flush()
259
+
260
+ except Exception as e:
261
+ print(f"[LLMOps-Observability] Error sending trace to Langfuse: {e}")
262
+ traceback.print_exc()
263
+
264
+ # End the trace
265
+ with cls._lock:
266
+ cls._end_trace_internal()
267
+
268
+ print(f"[LLMOps-Observability] Trace finalized and sent: {trace_name} (ID: {trace_id})")
269
+ return True
270
+
271
+
272
+ @classmethod
273
+ def start_observation_context(cls, span_name: str, span_type: str, input_data: Any):
274
+ """
275
+ Start a Langfuse observation context that will be the parent for nested calls.
276
+
277
+ Args:
278
+ span_name: Name of the span
279
+ span_type: Type ("span" or "generation")
280
+ input_data: Input data for the span
281
+
282
+ Returns:
283
+ observation context manager
284
+ """
285
+ with cls._lock:
286
+ trace_id = cls._active.get("trace_id")
287
+ trace_obj = cls._active.get("trace_obj")
288
+ current_obs_stack = cls._active.get("observation_stack", [])
289
+
290
+ if not trace_id or not trace_obj:
291
+ return None
292
+
293
+ langfuse = get_langfuse_client()
294
+
295
+ # Serialize input
296
+ serialized_input = serialize_value(input_data)
297
+
298
+ # Check if we have a parent observation
299
+ parent_obs = current_obs_stack[-1] if current_obs_stack else None
300
+
301
+ # Start observation context from parent or from langfuse root
302
+ if parent_obs:
303
+ # Create child observation from parent
304
+ if span_type == "generation":
305
+ obs_ctx = parent_obs.start_as_current_observation(
306
+ as_type="generation",
307
+ name=span_name,
308
+ input=serialized_input,
309
+ )
310
+ else:
311
+ obs_ctx = parent_obs.start_as_current_observation(
312
+ as_type="span",
313
+ name=span_name,
314
+ input=serialized_input,
315
+ )
316
+ else:
317
+ # Create root observation from langfuse client
318
+ # Build trace context
319
+ trace_context = {"trace_id": trace_id}
320
+ if trace_obj.get("user_id"):
321
+ trace_context["user_id"] = trace_obj.get("user_id")
322
+ if trace_obj.get("session_id"):
323
+ trace_context["session_id"] = trace_obj.get("session_id")
324
+
325
+ if span_type == "generation":
326
+ obs_ctx = langfuse.start_as_current_observation(
327
+ as_type="generation",
328
+ name=span_name,
329
+ trace_context=trace_context,
330
+ input=serialized_input,
331
+ )
332
+ else:
333
+ obs_ctx = langfuse.start_as_current_observation(
334
+ as_type="span",
335
+ name=span_name,
336
+ trace_context=trace_context,
337
+ input=serialized_input,
338
+ )
339
+
340
+ return obs_ctx
341
+
342
+ @classmethod
343
+ def push_observation(cls, obs):
344
+ """Push an observation onto the stack when entering its context"""
345
+ with cls._lock:
346
+ cls._active["observation_stack"].append(obs)
347
+
348
+ @classmethod
349
+ def pop_observation(cls):
350
+ """Pop an observation from the stack when exiting its context"""
351
+ with cls._lock:
352
+ if cls._active["observation_stack"]:
353
+ cls._active["observation_stack"].pop()
354
+
355
+
356
+ # ============================================================
357
+ # Local Capture Utilities (from veriskGO)
358
+ # ============================================================
359
+
360
+ def capture_function_locals(func, capture_locals=True, capture_self=True):
361
+ """
362
+ Capture local variables before and after function execution.
363
+ Uses sys.settrace for frame inspection (similar to veriskGO).
364
+
365
+ Args:
366
+ func: Function to capture locals from
367
+ capture_locals: Whether to capture locals (True, False, or list of var names)
368
+ capture_self: Whether to capture 'self' variable
369
+
370
+ Returns:
371
+ Tuple of (tracer, locals_before, locals_after)
372
+ """
373
+ locals_before = {}
374
+ locals_after = {}
375
+
376
+ if not capture_locals:
377
+ return None, locals_before, locals_after
378
+
379
+ target_code = func.__code__
380
+ target_name = func.__name__
381
+ target_module = func.__module__
382
+
383
+ entered = False
384
+
385
+ def tracer(frame, event, arg):
386
+ nonlocal entered
387
+
388
+ if frame.f_code is target_code and frame.f_globals.get("__name__") == target_module:
389
+ try:
390
+ if not entered:
391
+ entered = True
392
+ f_locals = frame.f_locals
393
+
394
+ # Filter specific variables if capture_locals is a list
395
+ if isinstance(capture_locals, list):
396
+ f_locals = {k: v for k, v in f_locals.items() if k in capture_locals}
397
+
398
+ if not capture_self and "self" in f_locals:
399
+ f_locals = {k: v for k, v in f_locals.items() if k != "self"}
400
+ locals_before.update(safe_locals(f_locals))
401
+
402
+ if event == "return":
403
+ f_locals = frame.f_locals
404
+
405
+ # Filter specific variables if capture_locals is a list
406
+ if isinstance(capture_locals, list):
407
+ f_locals = {k: v for k, v in f_locals.items() if k in capture_locals}
408
+
409
+ if not capture_self and "self" in f_locals:
410
+ f_locals = {k: v for k, v in f_locals.items() if k != "self"}
411
+ locals_after.update(safe_locals(f_locals))
412
+ locals_after["_return"] = serialize_value(arg)
413
+ except Exception as e:
414
+ print(f"[LLMOps-Observability] TRACER ERROR: {e}")
415
+ traceback.print_exc()
416
+
417
+ return tracer
418
+
419
+ return tracer, locals_before, locals_after
420
+
421
+
422
+ # ============================================================
423
+ # Decorator: track_function (Enhanced with veriskGO features)
424
+ # ============================================================
425
+
426
+ def track_function(
427
+ name: Optional[str] = None,
428
+ *,
429
+ tags: Optional[Dict[str, Any]] = None,
430
+ capture_locals: Union[bool, List[str]] = False,
431
+ capture_self: bool = False,
432
+ ):
433
+ """
434
+ Decorator to track function execution with Langfuse (enhanced version from veriskGO).
435
+
436
+ Features:
437
+ - Captures input arguments and output
438
+ - Optionally captures local variables before/after execution
439
+ - Manages span stack for proper nesting
440
+ - Sends directly to Langfuse (no batching)
441
+
442
+ Usage:
443
+ @track_function()
444
+ def process_data(input_data):
445
+ # Your code here
446
+ return result
447
+
448
+ @track_function(name="custom_name", tags={"version": "1.0"}, capture_locals=True)
449
+ def detailed_function(x, y):
450
+ result = x + y
451
+ return result
452
+
453
+ @track_function(capture_locals=["important_var"])
454
+ async def async_function():
455
+ important_var = "tracked"
456
+ return result
457
+
458
+ Args:
459
+ name: Optional custom name for the span
460
+ tags: Optional metadata tags
461
+ capture_locals: Capture local variables (True/False or list of var names)
462
+ capture_self: Whether to capture 'self' variable (default False)
463
+ """
464
+ def decorator(func):
465
+ span_name = name or func.__name__
466
+ is_async = inspect.iscoroutinefunction(func)
467
+
468
+ if is_async:
469
+ @functools.wraps(func)
470
+ async def async_wrapper(*args, **kwargs):
471
+ if not TraceManager.has_active_trace():
472
+ return await func(*args, **kwargs)
473
+
474
+ # Build input data
475
+ input_data = {
476
+ "args": serialize_value(args),
477
+ "kwargs": serialize_value(kwargs),
478
+ }
479
+
480
+ # Start observation context (this will be parent for nested calls)
481
+ obs_ctx = TraceManager.start_observation_context(span_name, "span", input_data)
482
+
483
+ if not obs_ctx:
484
+ return await func(*args, **kwargs)
485
+
486
+ # Setup local variable capture
487
+ tracer, locals_before, locals_after = capture_function_locals(
488
+ func, capture_locals=capture_locals, capture_self=capture_self
489
+ )
490
+
491
+ error = None
492
+ result = None
493
+ start_time = time.time()
494
+
495
+ # Use the observation context properly with 'with' statement
496
+ # This keeps the context active during function execution
497
+ with obs_ctx as obs:
498
+ # Push observation onto stack so nested calls become children
499
+ TraceManager.push_observation(obs)
500
+
501
+ if tracer:
502
+ sys.settrace(tracer)
503
+
504
+ try:
505
+ result = await func(*args, **kwargs)
506
+ except Exception as e:
507
+ error = e
508
+ raise
509
+ finally:
510
+ if tracer:
511
+ sys.settrace(None)
512
+
513
+ duration_ms = int((time.time() - start_time) * 1000)
514
+
515
+ # Build output
516
+ if error:
517
+ output_data = {
518
+ "status": "error",
519
+ "error": str(error),
520
+ "stacktrace": traceback.format_exc(),
521
+ "locals_before": locals_before,
522
+ "locals_after": locals_after,
523
+ }
524
+ else:
525
+ output_data = {
526
+ "status": "success",
527
+ "latency_ms": duration_ms,
528
+ "locals_before": locals_before,
529
+ "locals_after": locals_after,
530
+ "output": serialize_value(result),
531
+ }
532
+
533
+ # Update observation with output
534
+ obs.update(
535
+ output=serialize_value(output_data),
536
+ metadata=tags or {},
537
+ level="ERROR" if error else "DEFAULT",
538
+ status_message=str(error) if error else None,
539
+ )
540
+
541
+ # Note: flush happens after context exit
542
+
543
+ # Flush after exiting context
544
+ langfuse = get_langfuse_client()
545
+ langfuse.flush()
546
+
547
+ status_str = " (error)" if error else ""
548
+ print(f"[LLMOps-Observability] Span sent{status_str}: {span_name} ({duration_ms}ms)")
549
+
550
+ return result
551
+
552
+ return async_wrapper
553
+ else:
554
+ @functools.wraps(func)
555
+ def sync_wrapper(*args, **kwargs):
556
+ if not TraceManager.has_active_trace():
557
+ return func(*args, **kwargs)
558
+
559
+ # Build input data
560
+ input_data = {
561
+ "args": serialize_value(args),
562
+ "kwargs": serialize_value(kwargs),
563
+ }
564
+
565
+ # Start observation context (this will be parent for nested calls)
566
+ obs_ctx = TraceManager.start_observation_context(span_name, "span", input_data)
567
+
568
+ if not obs_ctx:
569
+ return func(*args, **kwargs)
570
+
571
+ # Setup local variable capture
572
+ tracer, locals_before, locals_after = capture_function_locals(
573
+ func, capture_locals=capture_locals, capture_self=capture_self
574
+ )
575
+
576
+ error = None
577
+ result = None
578
+ start_time = time.time()
579
+
580
+ # Use the observation context properly with 'with' statement
581
+ # This keeps the context active during function execution
582
+ with obs_ctx as obs:
583
+ # Push observation onto stack so nested calls become children
584
+ TraceManager.push_observation(obs)
585
+
586
+ if tracer:
587
+ sys.settrace(tracer)
588
+
589
+ try:
590
+ result = func(*args, **kwargs)
591
+ except Exception as e:
592
+ error = e
593
+ raise
594
+ finally:
595
+ # Pop observation from stack
596
+ TraceManager.pop_observation()
597
+
598
+ if tracer:
599
+ sys.settrace(None)
600
+
601
+ duration_ms = int((time.time() - start_time) * 1000)
602
+
603
+ # Build output
604
+ if error:
605
+ output_data = {
606
+ "status": "error",
607
+ "error": str(error),
608
+ "stacktrace": traceback.format_exc(),
609
+ "locals_before": locals_before,
610
+ "locals_after": locals_after,
611
+ }
612
+ else:
613
+ output_data = {
614
+ "status": "success",
615
+ "latency_ms": duration_ms,
616
+ "locals_before": locals_before,
617
+ "locals_after": locals_after,
618
+ "output": serialize_value(result),
619
+ }
620
+
621
+ # Update observation with output
622
+ obs.update(
623
+ output=serialize_value(output_data),
624
+ metadata=tags or {},
625
+ level="ERROR" if error else "DEFAULT",
626
+ status_message=str(error) if error else None,
627
+ )
628
+
629
+ # Flush after exiting context
630
+ langfuse = get_langfuse_client()
631
+ langfuse.flush()
632
+
633
+ status_str = " (error)" if error else ""
634
+ print(f"[LLMOps-Observability] Span sent{status_str}: {span_name} ({duration_ms}ms)")
635
+
636
+ return result
637
+
638
+ return sync_wrapper
639
+
640
+ return decorator
641
+