fluxloop 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.

Potentially problematic release.


This version of fluxloop might be problematic. Click here for more details.

fluxloop/context.py ADDED
@@ -0,0 +1,219 @@
1
+ """
2
+ Context management for tracing.
3
+ """
4
+
5
+ import contextvars
6
+ import random
7
+ from contextlib import contextmanager
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, List, Optional, Union
10
+ from uuid import UUID, uuid4
11
+
12
+ from .buffer import EventBuffer
13
+ from .config import get_config
14
+ from .models import ObservationData, TraceData
15
+
16
+
17
+ # Context variable for current FluxLoop context
18
+ _context_var: contextvars.ContextVar[Optional["FluxLoopContext"]] = contextvars.ContextVar(
19
+ "fluxloop_context", default=None
20
+ )
21
+
22
+
23
+ class FluxLoopContext:
24
+ """
25
+ Context for managing trace and observation hierarchy.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ trace_name: str,
31
+ trace_id: Optional[UUID] = None,
32
+ session_id: Optional[UUID] = None,
33
+ user_id: Optional[str] = None,
34
+ metadata: Optional[Dict[str, Any]] = None,
35
+ tags: Optional[List[str]] = None,
36
+ trace_id_override: Optional[Union[UUID, str]] = None,
37
+ ):
38
+ """
39
+ Initialize a new context.
40
+
41
+ Args:
42
+ trace_name: Name for the trace
43
+ trace_id: Optional trace ID (generated if not provided)
44
+ session_id: Optional session ID for grouping traces
45
+ user_id: Optional user identifier
46
+ metadata: Additional metadata
47
+ tags: Tags for categorization
48
+ """
49
+ self.config = get_config()
50
+ self.buffer = EventBuffer.get_instance()
51
+
52
+ # Create trace
53
+ trace_uuid = self._coerce_uuid(trace_id_override) or trace_id or uuid4()
54
+
55
+ self.trace = TraceData(
56
+ id=trace_uuid,
57
+ name=trace_name,
58
+ session_id=session_id,
59
+ user_id=user_id,
60
+ metadata=metadata or {},
61
+ tags=tags or [],
62
+ )
63
+
64
+ # Observation stack for hierarchy
65
+ self.observation_stack: List[ObservationData] = []
66
+ self.observations: List[ObservationData] = []
67
+
68
+ # Sampling decision
69
+ self.is_sampled = random.random() < self.config.sample_rate
70
+
71
+ def is_enabled(self) -> bool:
72
+ """Check if tracing is enabled and sampled."""
73
+ return self.config.enabled and self.is_sampled
74
+
75
+ def push_observation(self, observation: ObservationData) -> None:
76
+ """
77
+ Push a new observation onto the stack.
78
+
79
+ Args:
80
+ observation: Observation to push
81
+ """
82
+ if not self.is_enabled():
83
+ return
84
+
85
+ # Set trace ID
86
+ observation.trace_id = self.trace.id
87
+
88
+ # Set parent if there's an observation on the stack
89
+ if self.observation_stack:
90
+ parent = self.observation_stack[-1]
91
+ observation.parent_observation_id = parent.id
92
+
93
+ # Add to stack and list
94
+ self.observation_stack.append(observation)
95
+ self.observations.append(observation)
96
+
97
+ # Send to buffer if it's complete (has end_time)
98
+ if observation.end_time:
99
+ self.buffer.add_observation(self.trace.id, observation)
100
+
101
+ def pop_observation(self) -> Optional[ObservationData]:
102
+ """
103
+ Pop the current observation from the stack.
104
+
105
+ Returns:
106
+ The popped observation, or None if stack is empty
107
+ """
108
+ if not self.is_enabled() or not self.observation_stack:
109
+ return None
110
+
111
+ observation = self.observation_stack.pop()
112
+
113
+ # If observation doesn't have end_time, set it now
114
+ if not observation.end_time:
115
+ observation.end_time = datetime.now(timezone.utc)
116
+
117
+ # Send to buffer
118
+ self.buffer.add_observation(self.trace.id, observation)
119
+
120
+ return observation
121
+
122
+ def add_metadata(self, key: str, value: Any) -> None:
123
+ """Add metadata to the current trace."""
124
+ if self.is_enabled():
125
+ self.trace.metadata[key] = value
126
+
127
+ def add_tag(self, tag: str) -> None:
128
+ """Add a tag to the current trace."""
129
+ if self.is_enabled() and tag not in self.trace.tags:
130
+ self.trace.tags.append(tag)
131
+
132
+ @staticmethod
133
+ def _coerce_uuid(value: Optional[Union[UUID, str]]) -> Optional[UUID]:
134
+ if value is None:
135
+ return None
136
+ if isinstance(value, UUID):
137
+ return value
138
+ try:
139
+ return UUID(str(value))
140
+ except Exception:
141
+ return None
142
+
143
+ def set_user(self, user_id: str) -> None:
144
+ """Set the user ID for the trace."""
145
+ if self.is_enabled():
146
+ self.trace.user_id = user_id
147
+
148
+ def finalize(self) -> None:
149
+ """Finalize the trace and send all data."""
150
+ if not self.is_enabled():
151
+ return
152
+
153
+ # Pop any remaining observations
154
+ while self.observation_stack:
155
+ self.pop_observation()
156
+
157
+ # Set trace end time
158
+ if not self.trace.end_time:
159
+ self.trace.end_time = datetime.now(timezone.utc)
160
+
161
+ # Send trace to buffer
162
+ self.buffer.add_trace(self.trace)
163
+
164
+ # Trigger flush if needed
165
+ self.buffer.flush_if_needed()
166
+
167
+
168
+ def get_current_context() -> Optional[FluxLoopContext]:
169
+ """
170
+ Get the current FluxLoop context.
171
+
172
+ Returns:
173
+ Current context or None if not in a context
174
+ """
175
+ return _context_var.get()
176
+
177
+
178
+ @contextmanager
179
+ def instrument(
180
+ name: str,
181
+ session_id: Optional[UUID] = None,
182
+ user_id: Optional[str] = None,
183
+ metadata: Optional[Dict[str, Any]] = None,
184
+ tags: Optional[List[str]] = None,
185
+ trace_id: Optional[UUID] = None,
186
+ ):
187
+ """
188
+ Context manager for instrumenting code blocks.
189
+
190
+ Args:
191
+ name: Name for the trace
192
+ session_id: Optional session ID
193
+ user_id: Optional user identifier
194
+ metadata: Additional metadata
195
+ tags: Tags for categorization
196
+
197
+ Example:
198
+ >>> with fluxloop.instrument("my_workflow"):
199
+ ... result = my_agent.process(input_data)
200
+ """
201
+ # Create new context
202
+ context = FluxLoopContext(
203
+ trace_name=name,
204
+ session_id=session_id,
205
+ user_id=user_id,
206
+ metadata=metadata,
207
+ tags=tags,
208
+ trace_id_override=trace_id,
209
+ )
210
+
211
+ # Set as current context
212
+ token = _context_var.set(context)
213
+
214
+ try:
215
+ yield context
216
+ finally:
217
+ # Finalize and reset context
218
+ context.finalize()
219
+ _context_var.reset(token)
fluxloop/decorators.py ADDED
@@ -0,0 +1,465 @@
1
+ """
2
+ Decorators for instrumenting agent code.
3
+ """
4
+
5
+ import functools
6
+ import inspect
7
+ import time
8
+ import traceback
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Callable, Dict, Optional, TypeVar, Union
11
+ from uuid import UUID, uuid4
12
+
13
+ from .context import get_current_context
14
+ from .models import ObservationData, ObservationType
15
+
16
+ F = TypeVar("F", bound=Callable[..., Any])
17
+
18
+
19
+ def agent(
20
+ name: Optional[str] = None,
21
+ metadata: Optional[Dict[str, Any]] = None,
22
+ capture_input: bool = True,
23
+ capture_output: bool = True,
24
+ ) -> Callable[[F], F]:
25
+ """
26
+ Decorator for agent entry points.
27
+
28
+ Args:
29
+ name: Name for the agent trace (defaults to function name)
30
+ metadata: Additional metadata to attach
31
+ capture_input: Whether to capture function arguments
32
+ capture_output: Whether to capture return value
33
+
34
+ Example:
35
+ >>> @fluxloop.agent(name="ChatBot")
36
+ ... def process_message(message: str) -> str:
37
+ ... return f"Response to: {message}"
38
+ """
39
+
40
+ def decorator(func: F) -> F:
41
+ agent_name = name or func.__name__
42
+
43
+ @functools.wraps(func)
44
+ def sync_wrapper(*args, **kwargs):
45
+ context = get_current_context()
46
+ if not context or not context.is_enabled():
47
+ return func(*args, **kwargs)
48
+
49
+ # Create observation
50
+ obs_id = uuid4()
51
+ start_time = datetime.now(timezone.utc)
52
+
53
+ # Capture input
54
+ input_data = None
55
+ if capture_input:
56
+ input_data = _serialize_arguments(func, args, kwargs)
57
+
58
+ # Create observation data
59
+ observation = ObservationData(
60
+ id=obs_id,
61
+ type=ObservationType.AGENT,
62
+ name=agent_name,
63
+ start_time=start_time,
64
+ input=input_data,
65
+ metadata=metadata or {},
66
+ )
67
+
68
+ # Push to context
69
+ context.push_observation(observation)
70
+
71
+ try:
72
+ # Execute function
73
+ result = func(*args, **kwargs)
74
+
75
+ # Capture output
76
+ if capture_output:
77
+ observation.output = _serialize_value(result)
78
+
79
+ return result
80
+
81
+ except Exception as e:
82
+ # Capture error
83
+ observation.error = str(e)
84
+ observation.metadata["error_type"] = type(e).__name__
85
+ observation.metadata["traceback"] = traceback.format_exc()
86
+ raise
87
+
88
+ finally:
89
+ # Finalize observation
90
+ observation.end_time = datetime.now(timezone.utc)
91
+ context.pop_observation()
92
+
93
+ @functools.wraps(func)
94
+ async def async_wrapper(*args, **kwargs):
95
+ context = get_current_context()
96
+ if not context or not context.is_enabled():
97
+ return await func(*args, **kwargs)
98
+
99
+ # Create observation
100
+ obs_id = uuid4()
101
+ start_time = datetime.now(timezone.utc)
102
+
103
+ # Capture input
104
+ input_data = None
105
+ if capture_input:
106
+ input_data = _serialize_arguments(func, args, kwargs)
107
+
108
+ # Create observation data
109
+ observation = ObservationData(
110
+ id=obs_id,
111
+ type=ObservationType.AGENT,
112
+ name=agent_name,
113
+ start_time=start_time,
114
+ input=input_data,
115
+ metadata=metadata or {},
116
+ )
117
+
118
+ # Push to context
119
+ context.push_observation(observation)
120
+
121
+ try:
122
+ # Execute function
123
+ result = await func(*args, **kwargs)
124
+
125
+ # Capture output
126
+ if capture_output:
127
+ observation.output = _serialize_value(result)
128
+
129
+ return result
130
+
131
+ except Exception as e:
132
+ # Capture error
133
+ observation.error = str(e)
134
+ observation.metadata["error_type"] = type(e).__name__
135
+ observation.metadata["traceback"] = traceback.format_exc()
136
+ raise
137
+
138
+ finally:
139
+ # Finalize observation
140
+ observation.end_time = datetime.now(timezone.utc)
141
+ context.pop_observation()
142
+
143
+ # Return appropriate wrapper based on function type
144
+ if inspect.iscoroutinefunction(func):
145
+ return async_wrapper
146
+ else:
147
+ return sync_wrapper
148
+
149
+ return decorator
150
+
151
+
152
+ def prompt(
153
+ name: Optional[str] = None,
154
+ model: Optional[str] = None,
155
+ capture_tokens: bool = True,
156
+ ) -> Callable[[F], F]:
157
+ """
158
+ Decorator for prompt/LLM generation functions.
159
+
160
+ Args:
161
+ name: Name for the generation (defaults to function name)
162
+ model: Model name being used
163
+ capture_tokens: Whether to try to capture token usage
164
+
165
+ Example:
166
+ >>> @fluxloop.prompt(model="gpt-3.5-turbo")
167
+ ... def generate_response(prompt: str) -> str:
168
+ ... return llm.generate(prompt)
169
+ """
170
+
171
+ def decorator(func: F) -> F:
172
+ prompt_name = name or func.__name__
173
+
174
+ @functools.wraps(func)
175
+ def sync_wrapper(*args, **kwargs):
176
+ context = get_current_context()
177
+ if not context or not context.is_enabled():
178
+ return func(*args, **kwargs)
179
+
180
+ # Create observation
181
+ obs_id = uuid4()
182
+ start_time = datetime.now(timezone.utc)
183
+
184
+ # Capture input
185
+ input_data = _serialize_arguments(func, args, kwargs)
186
+
187
+ # Create observation data
188
+ observation = ObservationData(
189
+ id=obs_id,
190
+ type=ObservationType.GENERATION,
191
+ name=prompt_name,
192
+ start_time=start_time,
193
+ input=input_data,
194
+ model=model,
195
+ metadata={},
196
+ )
197
+
198
+ # Push to context
199
+ context.push_observation(observation)
200
+
201
+ try:
202
+ # Execute function
203
+ result = func(*args, **kwargs)
204
+
205
+ # Capture output
206
+ observation.output = _serialize_value(result)
207
+
208
+ # Try to extract token usage if result is a dict-like object
209
+ if capture_tokens and hasattr(result, "get"):
210
+ if "usage" in result:
211
+ usage = result["usage"]
212
+ observation.prompt_tokens = usage.get("prompt_tokens")
213
+ observation.completion_tokens = usage.get("completion_tokens")
214
+ observation.total_tokens = usage.get("total_tokens")
215
+
216
+ return result
217
+
218
+ except Exception as e:
219
+ # Capture error
220
+ observation.error = str(e)
221
+ observation.metadata["error_type"] = type(e).__name__
222
+ observation.metadata["traceback"] = traceback.format_exc()
223
+ raise
224
+
225
+ finally:
226
+ # Finalize observation
227
+ observation.end_time = datetime.now(timezone.utc)
228
+ context.pop_observation()
229
+
230
+ @functools.wraps(func)
231
+ async def async_wrapper(*args, **kwargs):
232
+ context = get_current_context()
233
+ if not context or not context.is_enabled():
234
+ return await func(*args, **kwargs)
235
+
236
+ # Create observation
237
+ obs_id = uuid4()
238
+ start_time = datetime.now(timezone.utc)
239
+
240
+ # Capture input
241
+ input_data = _serialize_arguments(func, args, kwargs)
242
+
243
+ # Create observation data
244
+ observation = ObservationData(
245
+ id=obs_id,
246
+ type=ObservationType.GENERATION,
247
+ name=prompt_name,
248
+ start_time=start_time,
249
+ input=input_data,
250
+ model=model,
251
+ metadata={},
252
+ )
253
+
254
+ # Push to context
255
+ context.push_observation(observation)
256
+
257
+ try:
258
+ # Execute function
259
+ result = await func(*args, **kwargs)
260
+
261
+ # Capture output
262
+ observation.output = _serialize_value(result)
263
+
264
+ # Try to extract token usage if result is a dict-like object
265
+ if capture_tokens and hasattr(result, "get"):
266
+ if "usage" in result:
267
+ usage = result["usage"]
268
+ observation.prompt_tokens = usage.get("prompt_tokens")
269
+ observation.completion_tokens = usage.get("completion_tokens")
270
+ observation.total_tokens = usage.get("total_tokens")
271
+
272
+ return result
273
+
274
+ except Exception as e:
275
+ # Capture error
276
+ observation.error = str(e)
277
+ observation.metadata["error_type"] = type(e).__name__
278
+ observation.metadata["traceback"] = traceback.format_exc()
279
+ raise
280
+
281
+ finally:
282
+ # Finalize observation
283
+ observation.end_time = datetime.now(timezone.utc)
284
+ context.pop_observation()
285
+
286
+ # Return appropriate wrapper based on function type
287
+ if inspect.iscoroutinefunction(func):
288
+ return async_wrapper
289
+ else:
290
+ return sync_wrapper
291
+
292
+ return decorator
293
+
294
+
295
+ def tool(
296
+ name: Optional[str] = None,
297
+ description: Optional[str] = None,
298
+ ) -> Callable[[F], F]:
299
+ """
300
+ Decorator for tool/function calls.
301
+
302
+ Args:
303
+ name: Name for the tool (defaults to function name)
304
+ description: Description of what the tool does
305
+
306
+ Example:
307
+ >>> @fluxloop.tool(description="Search the web")
308
+ ... def web_search(query: str) -> List[str]:
309
+ ... return search_engine.search(query)
310
+ """
311
+
312
+ def decorator(func: F) -> F:
313
+ tool_name = name or func.__name__
314
+
315
+ @functools.wraps(func)
316
+ def sync_wrapper(*args, **kwargs):
317
+ context = get_current_context()
318
+ if not context or not context.is_enabled():
319
+ return func(*args, **kwargs)
320
+
321
+ # Create observation
322
+ obs_id = uuid4()
323
+ start_time = datetime.now(timezone.utc)
324
+
325
+ # Capture input
326
+ input_data = _serialize_arguments(func, args, kwargs)
327
+
328
+ # Create observation data
329
+ observation = ObservationData(
330
+ id=obs_id,
331
+ type=ObservationType.TOOL,
332
+ name=tool_name,
333
+ start_time=start_time,
334
+ input=input_data,
335
+ metadata={"description": description} if description else {},
336
+ )
337
+
338
+ # Push to context
339
+ context.push_observation(observation)
340
+
341
+ try:
342
+ # Execute function
343
+ result = func(*args, **kwargs)
344
+
345
+ # Capture output
346
+ observation.output = _serialize_value(result)
347
+
348
+ return result
349
+
350
+ except Exception as e:
351
+ # Capture error
352
+ observation.error = str(e)
353
+ observation.metadata["error_type"] = type(e).__name__
354
+ observation.metadata["traceback"] = traceback.format_exc()
355
+ raise
356
+
357
+ finally:
358
+ # Finalize observation
359
+ observation.end_time = datetime.now(timezone.utc)
360
+ context.pop_observation()
361
+
362
+ @functools.wraps(func)
363
+ async def async_wrapper(*args, **kwargs):
364
+ context = get_current_context()
365
+ if not context or not context.is_enabled():
366
+ return await func(*args, **kwargs)
367
+
368
+ # Create observation
369
+ obs_id = uuid4()
370
+ start_time = datetime.now(timezone.utc)
371
+
372
+ # Capture input
373
+ input_data = _serialize_arguments(func, args, kwargs)
374
+
375
+ # Create observation data
376
+ observation = ObservationData(
377
+ id=obs_id,
378
+ type=ObservationType.TOOL,
379
+ name=tool_name,
380
+ start_time=start_time,
381
+ input=input_data,
382
+ metadata={"description": description} if description else {},
383
+ )
384
+
385
+ # Push to context
386
+ context.push_observation(observation)
387
+
388
+ try:
389
+ # Execute function
390
+ result = await func(*args, **kwargs)
391
+
392
+ # Capture output
393
+ observation.output = _serialize_value(result)
394
+
395
+ return result
396
+
397
+ except Exception as e:
398
+ # Capture error
399
+ observation.error = str(e)
400
+ observation.metadata["error_type"] = type(e).__name__
401
+ observation.metadata["traceback"] = traceback.format_exc()
402
+ raise
403
+
404
+ finally:
405
+ # Finalize observation
406
+ observation.end_time = datetime.now(timezone.utc)
407
+ context.pop_observation()
408
+
409
+ # Return appropriate wrapper based on function type
410
+ if inspect.iscoroutinefunction(func):
411
+ return async_wrapper
412
+ else:
413
+ return sync_wrapper
414
+
415
+ return decorator
416
+
417
+
418
+ def _serialize_arguments(func: Callable, args: tuple, kwargs: dict) -> Dict[str, Any]:
419
+ """Serialize function arguments for storage."""
420
+ sig = inspect.signature(func)
421
+ bound = sig.bind(*args, **kwargs)
422
+ bound.apply_defaults()
423
+
424
+ serialized = {}
425
+ for param_name, param_value in bound.arguments.items():
426
+ serialized[param_name] = _serialize_value(param_value)
427
+
428
+ return serialized
429
+
430
+
431
+ def _serialize_value(value: Any) -> Any:
432
+ """Serialize a value for storage."""
433
+ # Handle common types
434
+ if value is None or isinstance(value, (str, int, float, bool)):
435
+ return value
436
+
437
+ # Handle UUIDs
438
+ if isinstance(value, UUID):
439
+ return str(value)
440
+
441
+ # Handle datetime
442
+ if isinstance(value, datetime):
443
+ return value.isoformat()
444
+
445
+ # Handle lists/tuples
446
+ if isinstance(value, (list, tuple)):
447
+ return [_serialize_value(v) for v in value]
448
+
449
+ # Handle dicts
450
+ if isinstance(value, dict):
451
+ return {k: _serialize_value(v) for k, v in value.items()}
452
+
453
+ # Handle objects with dict representation
454
+ if hasattr(value, "dict"):
455
+ return value.dict()
456
+
457
+ # Handle objects with model_dump (Pydantic v2)
458
+ if hasattr(value, "model_dump"):
459
+ return value.model_dump()
460
+
461
+ # Fallback to string representation
462
+ try:
463
+ return str(value)
464
+ except:
465
+ return f"<{type(value).__name__}>"