fluxloop 0.1.4__tar.gz

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,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: fluxloop
3
+ Version: 0.1.4
4
+ Summary: FluxLoop SDK for agent instrumentation and tracing
5
+ Author-email: FluxLoop Team <team@fluxloop.dev>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/chuckgu/fluxloop
8
+ Project-URL: Documentation, https://docs.fluxloop.dev
9
+ Project-URL: Repository, https://github.com/chuckgu/fluxloop
10
+ Project-URL: Issues, https://github.com/chuckgu/fluxloop/issues
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.11
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: pydantic>=2.0
20
+ Requires-Dist: httpx>=0.24.0
21
+ Requires-Dist: python-dotenv>=1.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0; extra == "dev"
24
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
26
+ Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
27
+ Requires-Dist: pexpect>=4.9.0; extra == "dev"
28
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
29
+ Requires-Dist: mypy>=1.0; extra == "dev"
30
+ Requires-Dist: black>=23.0; extra == "dev"
31
+ Provides-Extra: langchain
32
+ Requires-Dist: langchain>=0.1.0; extra == "langchain"
33
+ Provides-Extra: langgraph
34
+ Requires-Dist: langgraph>=0.0.20; extra == "langgraph"
35
+
36
+ # FluxLoop SDK
37
+
38
+ FluxLoop SDK for agent instrumentation and tracing.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install fluxloop
44
+ ```
45
+
46
+ ## Quick Start
47
+
48
+ ```python
49
+ from fluxloop import trace, FluxLoopClient
50
+
51
+ # Initialize the client
52
+ client = FluxLoopClient()
53
+
54
+ # Use the trace decorator
55
+ @trace()
56
+ def my_agent_function(prompt: str):
57
+ # Your agent logic here
58
+ return result
59
+ ```
60
+
61
+ ## Features
62
+
63
+ - 🔍 **Automatic Tracing**: Instrument your agent code with simple decorators
64
+ - 📊 **Rich Context**: Capture inputs, outputs, and metadata
65
+ - 🔄 **Async Support**: Works with both sync and async functions
66
+ - 🎯 **Framework Integration**: Built-in support for LangChain and LangGraph
67
+
68
+ ## Documentation
69
+
70
+ For detailed documentation, visit [https://docs.fluxloop.dev](https://docs.fluxloop.dev)
71
+
72
+ ## License
73
+
74
+ Apache License 2.0 - see LICENSE file for details
75
+
76
+
77
+ ## Framework Integration: Decorator Ordering and Safe Instrumentation
78
+
79
+ When integrating FluxLoop with external agent frameworks (e.g., ChatKit, LangChain), follow these rules to avoid type conflicts and ensure observations are captured reliably.
80
+
81
+ - Outermost framework wrapper: If a framework provides its own decorator/wrapper that transforms a plain function into a framework-specific object (e.g., a Tool), that decorator MUST be the outermost (top) decorator. This preserves the type the framework expects.
82
+ - FluxLoop instrumentation inside: Place FluxLoop decorators inside (below) the framework decorator, or instrument from within the function body using the SDK context APIs.
83
+
84
+ Two safe patterns:
85
+
86
+ - Pattern A (safest, framework-agnostic): instrument inside the function body
87
+ - Use `get_current_context()` and push/pop an `ObservationData` manually around your logic. This keeps signatures and framework typing unchanged.
88
+ - Example (tool function):
89
+
90
+ ```python
91
+ from fluxloop import get_current_context
92
+ from fluxloop.models import ObservationData, ObservationType
93
+
94
+ async def my_tool(param: str) -> dict:
95
+ fl_ctx = get_current_context()
96
+ obs = None
97
+ if fl_ctx and fl_ctx.is_enabled():
98
+ obs = ObservationData(
99
+ type=ObservationType.TOOL,
100
+ name="tool.my_tool",
101
+ input={"args": {"param": param}},
102
+ )
103
+ fl_ctx.push_observation(obs)
104
+
105
+ try:
106
+ result = {"result": do_work(param)}
107
+ if obs:
108
+ obs.output = result
109
+ return result
110
+ except Exception as e:
111
+ if obs:
112
+ obs.error = str(e)
113
+ raise
114
+ finally:
115
+ if fl_ctx and obs:
116
+ fl_ctx.pop_observation()
117
+ ```
118
+
119
+ - Pattern B (stacking decorators): framework outermost, FluxLoop inside
120
+ - Example with a framework tool decorator:
121
+
122
+ ```python
123
+ @framework_tool_decorator(...)
124
+ @fluxloop.tool(name="tool.my_tool")
125
+ async def my_tool(...):
126
+ ...
127
+ ```
128
+
129
+ - Important: If you reverse the order (FluxLoop outside), the framework may see a plain function instead of its expected type and raise errors like "Unknown tool type".
130
+
131
+ LLM/streaming calls:
132
+
133
+ - For LLM calls (including async generators/streams), either:
134
+ - Wrap the call site in a small helper decorated with `@fluxloop.prompt(...)`, or
135
+ - Use `with fluxloop.instrument("prompt.name"):` around the portion that produces model output, ensuring it runs inside the current FluxLoop context.
136
+
137
+ These patterns guarantee observations are captured (`tool`, `generation`) while keeping the external framework’s type system intact.
138
+
@@ -0,0 +1,103 @@
1
+ # FluxLoop SDK
2
+
3
+ FluxLoop SDK for agent instrumentation and tracing.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install fluxloop
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from fluxloop import trace, FluxLoopClient
15
+
16
+ # Initialize the client
17
+ client = FluxLoopClient()
18
+
19
+ # Use the trace decorator
20
+ @trace()
21
+ def my_agent_function(prompt: str):
22
+ # Your agent logic here
23
+ return result
24
+ ```
25
+
26
+ ## Features
27
+
28
+ - 🔍 **Automatic Tracing**: Instrument your agent code with simple decorators
29
+ - 📊 **Rich Context**: Capture inputs, outputs, and metadata
30
+ - 🔄 **Async Support**: Works with both sync and async functions
31
+ - 🎯 **Framework Integration**: Built-in support for LangChain and LangGraph
32
+
33
+ ## Documentation
34
+
35
+ For detailed documentation, visit [https://docs.fluxloop.dev](https://docs.fluxloop.dev)
36
+
37
+ ## License
38
+
39
+ Apache License 2.0 - see LICENSE file for details
40
+
41
+
42
+ ## Framework Integration: Decorator Ordering and Safe Instrumentation
43
+
44
+ When integrating FluxLoop with external agent frameworks (e.g., ChatKit, LangChain), follow these rules to avoid type conflicts and ensure observations are captured reliably.
45
+
46
+ - Outermost framework wrapper: If a framework provides its own decorator/wrapper that transforms a plain function into a framework-specific object (e.g., a Tool), that decorator MUST be the outermost (top) decorator. This preserves the type the framework expects.
47
+ - FluxLoop instrumentation inside: Place FluxLoop decorators inside (below) the framework decorator, or instrument from within the function body using the SDK context APIs.
48
+
49
+ Two safe patterns:
50
+
51
+ - Pattern A (safest, framework-agnostic): instrument inside the function body
52
+ - Use `get_current_context()` and push/pop an `ObservationData` manually around your logic. This keeps signatures and framework typing unchanged.
53
+ - Example (tool function):
54
+
55
+ ```python
56
+ from fluxloop import get_current_context
57
+ from fluxloop.models import ObservationData, ObservationType
58
+
59
+ async def my_tool(param: str) -> dict:
60
+ fl_ctx = get_current_context()
61
+ obs = None
62
+ if fl_ctx and fl_ctx.is_enabled():
63
+ obs = ObservationData(
64
+ type=ObservationType.TOOL,
65
+ name="tool.my_tool",
66
+ input={"args": {"param": param}},
67
+ )
68
+ fl_ctx.push_observation(obs)
69
+
70
+ try:
71
+ result = {"result": do_work(param)}
72
+ if obs:
73
+ obs.output = result
74
+ return result
75
+ except Exception as e:
76
+ if obs:
77
+ obs.error = str(e)
78
+ raise
79
+ finally:
80
+ if fl_ctx and obs:
81
+ fl_ctx.pop_observation()
82
+ ```
83
+
84
+ - Pattern B (stacking decorators): framework outermost, FluxLoop inside
85
+ - Example with a framework tool decorator:
86
+
87
+ ```python
88
+ @framework_tool_decorator(...)
89
+ @fluxloop.tool(name="tool.my_tool")
90
+ async def my_tool(...):
91
+ ...
92
+ ```
93
+
94
+ - Important: If you reverse the order (FluxLoop outside), the framework may see a plain function instead of its expected type and raise errors like "Unknown tool type".
95
+
96
+ LLM/streaming calls:
97
+
98
+ - For LLM calls (including async generators/streams), either:
99
+ - Wrap the call site in a small helper decorated with `@fluxloop.prompt(...)`, or
100
+ - Use `with fluxloop.instrument("prompt.name"):` around the portion that produces model output, ensuring it runs inside the current FluxLoop context.
101
+
102
+ These patterns guarantee observations are captured (`tool`, `generation`) while keeping the external framework’s type system intact.
103
+
@@ -0,0 +1,64 @@
1
+ """
2
+ FluxLoop SDK - Agent instrumentation and tracing library.
3
+ """
4
+
5
+ from .context import FluxLoopContext, get_current_context, instrument
6
+ from .decorators import agent, prompt, tool
7
+ from .schemas import (
8
+ ExperimentConfig,
9
+ PersonaConfig,
10
+ RunnerConfig,
11
+ VariationStrategy,
12
+ Trace,
13
+ Observation,
14
+ ObservationType,
15
+ ObservationLevel,
16
+ Score,
17
+ ScoreDataType,
18
+ TraceStatus,
19
+ )
20
+ from .client import FluxLoopClient
21
+ from .config import configure, get_config, reset_config, load_env
22
+ from .recording import (
23
+ disable_recording,
24
+ enable_recording,
25
+ record_call_args,
26
+ set_recording_options,
27
+ )
28
+
29
+ __version__ = "0.1.4"
30
+
31
+ __all__ = [
32
+ # Decorators
33
+ "agent",
34
+ "prompt",
35
+ "tool",
36
+ # Context
37
+ "instrument",
38
+ "get_current_context",
39
+ "FluxLoopContext",
40
+ # Client
41
+ "FluxLoopClient",
42
+ # Config
43
+ "configure",
44
+ "load_env",
45
+ "get_config",
46
+ "reset_config",
47
+ "enable_recording",
48
+ "disable_recording",
49
+ "set_recording_options",
50
+ "record_call_args",
51
+ # Schemas - configs
52
+ "ExperimentConfig",
53
+ "PersonaConfig",
54
+ "RunnerConfig",
55
+ "VariationStrategy",
56
+ # Schemas - tracing
57
+ "Trace",
58
+ "Observation",
59
+ "ObservationType",
60
+ "ObservationLevel",
61
+ "Score",
62
+ "ScoreDataType",
63
+ "TraceStatus",
64
+ ]
@@ -0,0 +1,184 @@
1
+ """
2
+ Event buffering and batch sending logic.
3
+ """
4
+
5
+ import atexit
6
+ import threading
7
+ import time
8
+ from collections import deque
9
+ from typing import Deque, List, Optional, Tuple
10
+ from uuid import UUID
11
+
12
+ from .config import get_config
13
+ from .models import ObservationData, TraceData
14
+ from .storage import OfflineStore
15
+
16
+
17
+ class EventBuffer:
18
+ """
19
+ Singleton buffer for collecting and batching events.
20
+ """
21
+
22
+ _instance: Optional["EventBuffer"] = None
23
+ _lock = threading.Lock()
24
+
25
+ def __init__(self) -> None:
26
+ """Initialize the buffer."""
27
+ if EventBuffer._instance is not None:
28
+ raise RuntimeError("Use EventBuffer.get_instance() instead")
29
+
30
+ self.config = get_config()
31
+ self.traces: Deque[TraceData] = deque(maxlen=self.config.max_queue_size)
32
+ self.observations: Deque[Tuple[UUID, ObservationData]] = deque(
33
+ maxlen=self.config.max_queue_size
34
+ )
35
+
36
+ # Threading
37
+ self.send_lock = threading.Lock()
38
+ self.last_flush = time.time()
39
+
40
+ # Background thread for periodic flushing
41
+ self.stop_event = threading.Event()
42
+ self.flush_thread = threading.Thread(
43
+ target=self._flush_periodically, daemon=True
44
+ )
45
+ self.flush_thread.start()
46
+
47
+ # Register cleanup on exit
48
+ atexit.register(self.shutdown)
49
+
50
+ # Offline store
51
+ self.offline_store = OfflineStore()
52
+
53
+ @classmethod
54
+ def get_instance(cls) -> "EventBuffer":
55
+ """Get or create the singleton instance."""
56
+ if cls._instance is None:
57
+ with cls._lock:
58
+ if cls._instance is None:
59
+ cls._instance = cls()
60
+ return cls._instance
61
+
62
+ def add_trace(self, trace: TraceData) -> None:
63
+ """
64
+ Add a trace to the buffer.
65
+
66
+ Args:
67
+ trace: Trace data to add
68
+ """
69
+ if not self.config.enabled:
70
+ return
71
+
72
+ with self.send_lock:
73
+ self.traces.append(trace)
74
+
75
+ def add_observation(self, trace_id: UUID, observation: ObservationData) -> None:
76
+ """
77
+ Add an observation to the buffer.
78
+
79
+ Args:
80
+ trace_id: ID of the parent trace
81
+ observation: Observation data to add
82
+ """
83
+ if not self.config.enabled:
84
+ return
85
+
86
+ with self.send_lock:
87
+ self.observations.append((trace_id, observation))
88
+
89
+ def flush_if_needed(self) -> None:
90
+ """Flush the buffer if batch size is reached."""
91
+ should_flush = False
92
+
93
+ with self.send_lock:
94
+ total_items = len(self.traces) + len(self.observations)
95
+ should_flush = total_items >= self.config.batch_size
96
+
97
+ if should_flush:
98
+ self.flush()
99
+
100
+ def flush(self) -> None:
101
+ """Send all buffered events to the collector."""
102
+ if not self.config.enabled:
103
+ return
104
+
105
+ # Collect items to send
106
+ traces_to_send: List[TraceData] = []
107
+ observations_to_send: List[Tuple[UUID, ObservationData]] = []
108
+
109
+ with self.send_lock:
110
+ # Move items from buffer
111
+ while self.traces:
112
+ traces_to_send.append(self.traces.popleft())
113
+
114
+ while self.observations:
115
+ observations_to_send.append(self.observations.popleft())
116
+
117
+ self.last_flush = time.time()
118
+
119
+ # Send if there's data
120
+ if traces_to_send or observations_to_send:
121
+ self._send_batch(traces_to_send, observations_to_send)
122
+
123
+ def _send_batch(
124
+ self, traces: List[TraceData], observations: List[Tuple[UUID, ObservationData]]
125
+ ) -> None:
126
+ """
127
+ Send a batch of events to the collector.
128
+
129
+ Args:
130
+ traces: List of traces to send
131
+ observations: List of (trace_id, observation) tuples
132
+ """
133
+ # Import here to avoid circular dependency
134
+ send_errors = False
135
+
136
+ if self.config.use_collector:
137
+ from .client import FluxLoopClient
138
+
139
+ client = FluxLoopClient()
140
+
141
+ for trace in traces:
142
+ try:
143
+ client.send_trace(trace)
144
+ except Exception as e:
145
+ send_errors = True
146
+ if self.config.debug:
147
+ print(f"Failed to send trace {trace.id}: {e}")
148
+
149
+ for trace_id, observation in observations:
150
+ try:
151
+ client.send_observation(trace_id, observation)
152
+ except Exception as e:
153
+ send_errors = True
154
+ if self.config.debug:
155
+ print(f"Failed to send observation {observation.id}: {e}")
156
+
157
+ if send_errors or not self.config.use_collector:
158
+ self.offline_store.record_traces(traces)
159
+ self.offline_store.record_observations(observations)
160
+
161
+ def _flush_periodically(self) -> None:
162
+ """Background thread to flush periodically."""
163
+ while not self.stop_event.is_set():
164
+ time.sleep(self.config.flush_interval)
165
+
166
+ # Check if enough time has passed since last flush
167
+ with self.send_lock:
168
+ time_since_flush = time.time() - self.last_flush
169
+ has_data = bool(self.traces or self.observations)
170
+
171
+ if has_data and time_since_flush >= self.config.flush_interval:
172
+ self.flush()
173
+
174
+ def shutdown(self) -> None:
175
+ """Shutdown the buffer and flush remaining events."""
176
+ # Stop the background thread
177
+ self.stop_event.set()
178
+
179
+ # Final flush
180
+ self.flush()
181
+
182
+ # Wait for thread to stop (with timeout)
183
+ if self.flush_thread.is_alive():
184
+ self.flush_thread.join(timeout=2.0)
@@ -0,0 +1,182 @@
1
+ """
2
+ HTTP client for sending data to the collector.
3
+ """
4
+
5
+ from types import TracebackType
6
+ from typing import Any, Dict, Optional, Type, cast
7
+ from uuid import UUID
8
+
9
+ import httpx
10
+
11
+ from .config import get_config
12
+ from .models import ObservationData, TraceData
13
+
14
+
15
+ class FluxLoopClient:
16
+ """
17
+ HTTP client for communicating with the FluxLoop collector.
18
+ """
19
+
20
+ def __init__(
21
+ self, collector_url: Optional[str] = None, api_key: Optional[str] = None
22
+ ):
23
+ """
24
+ Initialize the client.
25
+
26
+ Args:
27
+ collector_url: Override collector URL
28
+ api_key: Override API key
29
+ """
30
+ self.config = get_config()
31
+ default_url = self.config.collector_url or "http://localhost:8000"
32
+ self.collector_url = (collector_url or default_url).rstrip("/")
33
+ self.api_key = api_key or self.config.api_key
34
+ self._client: Optional[httpx.Client] = None
35
+ if self.config.use_collector:
36
+ self._client = httpx.Client(
37
+ base_url=self.collector_url,
38
+ timeout=self.config.timeout,
39
+ headers=self._get_headers(),
40
+ )
41
+
42
+ def _get_headers(self) -> Dict[str, str]:
43
+ """Get common headers for requests."""
44
+ headers = {
45
+ "Content-Type": "application/json",
46
+ "User-Agent": "fluxloop-sdk/0.1.0",
47
+ }
48
+
49
+ if self.api_key:
50
+ headers["Authorization"] = f"Bearer {self.api_key}"
51
+
52
+ if self.config.service_name:
53
+ headers["X-Service-Name"] = self.config.service_name
54
+
55
+ if self.config.environment:
56
+ headers["X-Environment"] = self.config.environment
57
+
58
+ return headers
59
+
60
+ def send_trace(self, trace: TraceData) -> Dict[str, Any]:
61
+ """
62
+ Send a trace to the collector.
63
+
64
+ Args:
65
+ trace: Trace data to send
66
+
67
+ Returns:
68
+ Response from the collector
69
+
70
+ Raises:
71
+ httpx.HTTPError: If the request fails
72
+ """
73
+ if not self.config.enabled:
74
+ return {"status": "disabled"}
75
+
76
+ # Convert to JSON-serializable format
77
+ payload = self._serialize_trace(trace)
78
+
79
+ if not self._client:
80
+ return {"status": "collector_disabled"}
81
+
82
+ try:
83
+ response = self._client.post("/api/traces", json=payload)
84
+ response.raise_for_status()
85
+ return cast(Dict[str, Any], response.json())
86
+ except httpx.HTTPError as e:
87
+ if self.config.debug:
88
+ print(f"Error sending trace: {e}")
89
+ raise
90
+
91
+ def send_observation(
92
+ self, trace_id: UUID, observation: ObservationData
93
+ ) -> Dict[str, Any]:
94
+ """
95
+ Send an observation to the collector.
96
+
97
+ Args:
98
+ trace_id: ID of the parent trace
99
+ observation: Observation data to send
100
+
101
+ Returns:
102
+ Response from the collector
103
+
104
+ Raises:
105
+ httpx.HTTPError: If the request fails
106
+ """
107
+ if not self.config.enabled:
108
+ return {"status": "disabled"}
109
+
110
+ # Convert to JSON-serializable format
111
+ payload = self._serialize_observation(observation)
112
+ payload["trace_id"] = str(trace_id)
113
+
114
+ if not self._client:
115
+ return {"status": "collector_disabled"}
116
+
117
+ try:
118
+ response = self._client.post(
119
+ f"/api/traces/{trace_id}/observations", json=payload
120
+ )
121
+ response.raise_for_status()
122
+ return cast(Dict[str, Any], response.json())
123
+ except httpx.HTTPError as e:
124
+ if self.config.debug:
125
+ print(f"Error sending observation: {e}")
126
+ raise
127
+
128
+ def _serialize_trace(self, trace: TraceData) -> Dict[str, Any]:
129
+ """Serialize trace for JSON transmission."""
130
+ data = trace.model_dump(exclude_none=True)
131
+
132
+ # Convert UUIDs to strings
133
+ if "id" in data:
134
+ data["id"] = str(data["id"])
135
+ if "session_id" in data:
136
+ data["session_id"] = str(data["session_id"])
137
+
138
+ # Convert datetime to ISO format
139
+ if "start_time" in data:
140
+ data["start_time"] = data["start_time"].isoformat()
141
+ if "end_time" in data and data["end_time"]:
142
+ data["end_time"] = data["end_time"].isoformat()
143
+
144
+ return data
145
+
146
+ def _serialize_observation(self, observation: ObservationData) -> Dict[str, Any]:
147
+ """Serialize observation for JSON transmission."""
148
+ data = observation.model_dump(exclude_none=True)
149
+
150
+ # Convert UUIDs to strings
151
+ if "id" in data:
152
+ data["id"] = str(data["id"])
153
+ if "parent_observation_id" in data:
154
+ data["parent_observation_id"] = str(data["parent_observation_id"])
155
+ if "trace_id" in data:
156
+ data["trace_id"] = str(data["trace_id"])
157
+
158
+ # Convert datetime to ISO format
159
+ if "start_time" in data:
160
+ data["start_time"] = data["start_time"].isoformat()
161
+ if "end_time" in data and data["end_time"]:
162
+ data["end_time"] = data["end_time"].isoformat()
163
+
164
+ return data
165
+
166
+ def close(self) -> None:
167
+ """Close the HTTP client."""
168
+ if self._client:
169
+ self._client.close()
170
+
171
+ def __enter__(self) -> "FluxLoopClient":
172
+ """Context manager entry."""
173
+ return self
174
+
175
+ def __exit__(
176
+ self,
177
+ exc_type: Optional[Type[BaseException]],
178
+ exc_val: Optional[BaseException],
179
+ exc_tb: Optional[TracebackType],
180
+ ) -> None:
181
+ """Context manager exit."""
182
+ self.close()