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.
- fluxloop-0.1.4/PKG-INFO +138 -0
- fluxloop-0.1.4/README.md +103 -0
- fluxloop-0.1.4/fluxloop/__init__.py +64 -0
- fluxloop-0.1.4/fluxloop/buffer.py +184 -0
- fluxloop-0.1.4/fluxloop/client.py +182 -0
- fluxloop-0.1.4/fluxloop/config.py +242 -0
- fluxloop-0.1.4/fluxloop/context.py +219 -0
- fluxloop-0.1.4/fluxloop/decorators.py +465 -0
- fluxloop-0.1.4/fluxloop/models.py +92 -0
- fluxloop-0.1.4/fluxloop/recording.py +213 -0
- fluxloop-0.1.4/fluxloop/schemas/__init__.py +48 -0
- fluxloop-0.1.4/fluxloop/schemas/config.py +352 -0
- fluxloop-0.1.4/fluxloop/schemas/trace.py +200 -0
- fluxloop-0.1.4/fluxloop/serialization.py +112 -0
- fluxloop-0.1.4/fluxloop/storage.py +52 -0
- fluxloop-0.1.4/fluxloop.egg-info/PKG-INFO +138 -0
- fluxloop-0.1.4/fluxloop.egg-info/SOURCES.txt +25 -0
- fluxloop-0.1.4/fluxloop.egg-info/dependency_links.txt +1 -0
- fluxloop-0.1.4/fluxloop.egg-info/requires.txt +19 -0
- fluxloop-0.1.4/fluxloop.egg-info/top_level.txt +1 -0
- fluxloop-0.1.4/pyproject.toml +74 -0
- fluxloop-0.1.4/setup.cfg +4 -0
- fluxloop-0.1.4/tests/test_buffer.py +74 -0
- fluxloop-0.1.4/tests/test_config.py +175 -0
- fluxloop-0.1.4/tests/test_context.py +133 -0
- fluxloop-0.1.4/tests/test_decorators.py +168 -0
- fluxloop-0.1.4/tests/test_recording.py +164 -0
fluxloop-0.1.4/PKG-INFO
ADDED
|
@@ -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
|
+
|
fluxloop-0.1.4/README.md
ADDED
|
@@ -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()
|