infralo 0.1.0.dev1__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.
infralo/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ infralo — Observability SDK for agentic AI workflows.
3
+ """
4
+
5
+ from infralo.client import Infralo
6
+ from infralo.trace import get_active_trace
7
+
8
+ __all__ = ["Infralo", "get_active_trace"]
9
+ __version__ = "0.1.0"
infralo/_models.py ADDED
@@ -0,0 +1,84 @@
1
+ """
2
+ infralo — Internal span data model.
3
+
4
+ ToolSpanData mirrors the ToolCallSpanCreate API schema and is serialised
5
+ to JSON before submission to the /api/v1/traces/spans/tool-call endpoint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ _PREVIEW_MAX_CHARS = 500
15
+
16
+
17
+ def _to_preview(value: Any) -> tuple[str, bool]:
18
+ """Return (preview_str, is_truncated) for any value."""
19
+ text = value if isinstance(value, str) else str(value)
20
+ if len(text) > _PREVIEW_MAX_CHARS:
21
+ return text[:_PREVIEW_MAX_CHARS], True
22
+ return text, False
23
+
24
+
25
+ def _isoformat(dt: datetime) -> str:
26
+ if dt.tzinfo is None:
27
+ dt = dt.replace(tzinfo=timezone.utc)
28
+ return dt.isoformat()
29
+
30
+
31
+ @dataclass
32
+ class ToolSpanData:
33
+ """
34
+ Internal representation of one tool call span.
35
+ Serialises to the ToolCallSpanCreate API body shape.
36
+ """
37
+
38
+ trace_id: str
39
+ span_id: str
40
+ parent_span_id: str
41
+ sequence_number: int
42
+ tool_name: str
43
+ tool_call_id: str
44
+ start_time: datetime
45
+ end_time: datetime
46
+
47
+ session_id: str = ""
48
+ status: str = "success" # "success" | "error" | "timeout"
49
+ error_code: str | None = None
50
+ error_message: str | None = None
51
+
52
+ input_preview: str = ""
53
+ is_input_truncated: bool = False
54
+ output_preview: str = ""
55
+ is_output_truncated: bool = False
56
+
57
+ input_payload: dict | None = None
58
+ output_payload: Any = None
59
+
60
+ metadata: dict = field(default_factory=dict)
61
+
62
+ def to_dict(self) -> dict:
63
+ """Serialise to the ToolCallSpanCreate JSON shape."""
64
+ return {
65
+ "trace_id": self.trace_id,
66
+ "span_id": self.span_id,
67
+ "parent_span_id": self.parent_span_id,
68
+ "sequence_number": self.sequence_number,
69
+ "tool_name": self.tool_name,
70
+ "tool_call_id": self.tool_call_id,
71
+ "start_time": _isoformat(self.start_time),
72
+ "end_time": _isoformat(self.end_time),
73
+ "session_id": self.session_id,
74
+ "status": self.status,
75
+ "error_code": self.error_code,
76
+ "error_message": self.error_message,
77
+ "input_preview": self.input_preview,
78
+ "is_input_truncated": self.is_input_truncated,
79
+ "output_preview": self.output_preview,
80
+ "is_output_truncated": self.is_output_truncated,
81
+ "input_payload": self.input_payload,
82
+ "output_payload": self.output_payload,
83
+ "metadata": self.metadata,
84
+ }
infralo/client.py ADDED
@@ -0,0 +1,251 @@
1
+ """
2
+ infralo — Infralo client: main entry point.
3
+
4
+ from infralo import Infralo
5
+
6
+ infralo = Infralo(api_key="vk_your_key")
7
+
8
+ with infralo.start_trace(session_id="user-123") as trace:
9
+ ...
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import atexit
16
+ import functools
17
+ import inspect
18
+ import uuid
19
+ from typing import Any, Callable, TypeVar
20
+
21
+ from infralo.exporter import SpanExporter
22
+ from infralo.trace import Trace, _active_trace
23
+
24
+ F = TypeVar("F", bound=Callable)
25
+
26
+
27
+ class Infralo:
28
+ """
29
+ Main Infralo SDK client.
30
+
31
+ Create one instance per application (typically at module level) and reuse it.
32
+ The background exporter starts immediately and flushes remaining spans on exit.
33
+
34
+ Args:
35
+ api_key: Your Infralo Virtual API Key (starts with vk_).
36
+ endpoint: Base URL of your Infralo gateway. Default: https://api.infralo.com
37
+ flush_interval: Seconds between background flush cycles. Default: 5.0
38
+ max_batch_size: Max spans per HTTP request. Default: 50
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ api_key: str,
44
+ endpoint: str = "https://api.infralo.com",
45
+ flush_interval: float = 5.0,
46
+ max_batch_size: int = 50,
47
+ max_queue_size: int = 10000,
48
+ ) -> None:
49
+ self._exporter = SpanExporter(
50
+ api_key=api_key,
51
+ endpoint=endpoint,
52
+ flush_interval=flush_interval,
53
+ max_batch_size=max_batch_size,
54
+ max_queue_size=max_queue_size,
55
+ )
56
+ # Guaranteed final flush when the process exits (scripts, workers, servers)
57
+ atexit.register(self._exporter.flush)
58
+
59
+ # ── Trace factory ────────────────────────────────────────────────────────
60
+
61
+ def start_trace(
62
+ self,
63
+ trace_id: str | None = None,
64
+ session_id: str = "",
65
+ ) -> Trace:
66
+ """
67
+ Create a new Trace for one agentic chain run.
68
+
69
+ Args:
70
+ trace_id: Shared across the entire chain. Auto-generated UUID if omitted.
71
+ Pass the same trace_id on every LLM gateway call via the
72
+ x-infralo-trace-id header.
73
+ session_id: Optional user/session identifier for grouping traces
74
+ (e.g., a user_id, conversation_id).
75
+
76
+ Usage:
77
+ with infralo.start_trace(session_id="user-123") as trace:
78
+ response = client.chat.completions.create(
79
+ model="my-deployment",
80
+ messages=[...],
81
+ extra_headers={"x-infralo-trace-id": trace.trace_id},
82
+ )
83
+ trace.update_from_response(response)
84
+ ...
85
+ """
86
+ return Trace(
87
+ trace_id=trace_id or str(uuid.uuid4()),
88
+ session_id=session_id,
89
+ exporter=self._exporter,
90
+ )
91
+
92
+ # ── @tool decorator ──────────────────────────────────────────────────────
93
+
94
+ def tool(
95
+ self,
96
+ fn: F | None = None,
97
+ *,
98
+ name: str | None = None,
99
+ capture_input: bool = True,
100
+ capture_output: bool = True,
101
+ ) -> F | Callable[[F], F]:
102
+ """
103
+ Decorator that automatically wraps a sync or async function as a tool span.
104
+
105
+ Reads the active Trace from contextvars — must be called inside a
106
+ `with infralo.start_trace(...):` block. Works transparently with both
107
+ sync and async functions.
108
+
109
+ Args:
110
+ name: Override the span name (defaults to the function's __name__).
111
+ capture_input: Auto-capture all function kwargs as input_payload
112
+ and input_preview. Default True.
113
+ Set False to avoid storing sensitive arguments (e.g., passwords).
114
+ capture_output: Auto-capture the return value as output_payload
115
+ and output_preview. Default True.
116
+ Set False to avoid storing sensitive outputs.
117
+
118
+ Metadata:
119
+ Pass _infralo_metadata={...} as a keyword argument to the decorated
120
+ function to attach custom metadata to the span:
121
+ result = my_tool(query="test", _infralo_metadata={"version": "2"})
122
+
123
+ Usage:
124
+ @infralo.tool
125
+ def search_web(query: str) -> list[dict]:
126
+ return search_api(query)
127
+
128
+ @infralo.tool(name="custom_name", capture_input=False)
129
+ async def search_web(query: str) -> list[dict]:
130
+ return await async_search_api(query)
131
+
132
+ Parallel usage (inside `async with trace.parallel():`):
133
+ async with trace.parallel():
134
+ results = await asyncio.gather(
135
+ search_web("q1"), # @infralo.tool — shares seq number
136
+ fetch_doc("url"), # @infralo.tool — shares seq number
137
+ )
138
+ """
139
+
140
+ def decorator(func: F) -> F:
141
+ tool_name = name or func.__name__
142
+
143
+ def _build_input(args: tuple, kwargs: dict) -> dict | None:
144
+ if not capture_input:
145
+ return None
146
+ try:
147
+ sig = inspect.signature(func)
148
+ bound = sig.bind(*args, **kwargs)
149
+ bound.apply_defaults()
150
+ # Exclude private infralo kwargs
151
+ return {
152
+ k: v for k, v in bound.arguments.items() if not k.startswith("_infralo_")
153
+ }
154
+ except Exception:
155
+ return None
156
+
157
+ def _pop_infralo_kwargs(kwargs: dict) -> dict:
158
+ """Extract and remove _infralo_* kwargs before calling the real function."""
159
+ return {k: kwargs.pop(k) for k in list(kwargs) if k.startswith("_infralo_")}
160
+
161
+ @functools.wraps(func)
162
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
163
+ trace = _active_trace.get()
164
+ if trace is None:
165
+ raise RuntimeError(
166
+ f"@infralo.tool '{tool_name}' was called outside of an active trace. "
167
+ "Wrap your code with `with infralo.start_trace(): ...`."
168
+ )
169
+
170
+ infralo_kwargs = _pop_infralo_kwargs(kwargs)
171
+ metadata: dict = infralo_kwargs.get("_infralo_metadata", {})
172
+ tool_call_id: str = infralo_kwargs.get("_infralo_tool_call_id", str(uuid.uuid4()))
173
+ seq_override: int | None = infralo_kwargs.get("_infralo_sequence_number")
174
+ parent_override: str | None = infralo_kwargs.get("_infralo_parent_span_id")
175
+
176
+ input_data = _build_input(args, dict(kwargs))
177
+
178
+ with trace.tool(
179
+ name=tool_name,
180
+ tool_call_id=tool_call_id,
181
+ sequence_number=seq_override,
182
+ parent_span_id=parent_override,
183
+ ) as span:
184
+ if input_data is not None:
185
+ span.set_input(input_data, capture_payload=capture_input)
186
+ if metadata:
187
+ span.set_metadata(**metadata)
188
+ result = func(*args, **kwargs)
189
+ if capture_output:
190
+ span.set_output(result, capture_payload=capture_output)
191
+ return result
192
+
193
+ @functools.wraps(func)
194
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
195
+ trace = _active_trace.get()
196
+ if trace is None:
197
+ raise RuntimeError(
198
+ f"@infralo.tool '{tool_name}' was called outside of an active trace. "
199
+ "Wrap your code with `with infralo.start_trace(): ...`."
200
+ )
201
+
202
+ infralo_kwargs = _pop_infralo_kwargs(kwargs)
203
+ metadata: dict = infralo_kwargs.get("_infralo_metadata", {})
204
+ tool_call_id: str = infralo_kwargs.get("_infralo_tool_call_id", str(uuid.uuid4()))
205
+ seq_override: int | None = infralo_kwargs.get("_infralo_sequence_number")
206
+ parent_override: str | None = infralo_kwargs.get("_infralo_parent_span_id")
207
+
208
+ input_data = _build_input(args, dict(kwargs))
209
+
210
+ with trace.tool(
211
+ name=tool_name,
212
+ tool_call_id=tool_call_id,
213
+ sequence_number=seq_override,
214
+ parent_span_id=parent_override,
215
+ ) as span:
216
+ if input_data is not None:
217
+ span.set_input(input_data, capture_payload=capture_input)
218
+ if metadata:
219
+ span.set_metadata(**metadata)
220
+ result = await func(*args, **kwargs)
221
+ if capture_output:
222
+ span.set_output(result, capture_payload=capture_output)
223
+ return result
224
+
225
+ # Transparently support both sync and async functions
226
+ if asyncio.iscoroutinefunction(func):
227
+ return async_wrapper # type: ignore[return-value]
228
+ return sync_wrapper # type: ignore[return-value]
229
+
230
+ # Support both @infralo.tool and @infralo.tool(name="...", ...)
231
+ if fn is not None:
232
+ return decorator(fn)
233
+ return decorator
234
+
235
+ # ── Utilities ────────────────────────────────────────────────────────────
236
+
237
+ def flush(self, timeout: float = 10.0) -> None:
238
+ """
239
+ Manually flush all pending spans. Useful in tests or before shutdown.
240
+
241
+ Calling this is NOT required in production — spans are flushed automatically
242
+ by the background thread and on process exit.
243
+ """
244
+ self._exporter.flush(timeout)
245
+
246
+ def shutdown(self) -> None:
247
+ """
248
+ Shutdown the background exporter gracefully. Called automatically on exit.
249
+ Safe to call manually if you need to ensure spans are sent before teardown.
250
+ """
251
+ self._exporter.shutdown()
infralo/exporter.py ADDED
@@ -0,0 +1,154 @@
1
+ """
2
+ infralo — Background HTTP span exporter.
3
+
4
+ Spans are pushed to a thread-safe queue and flushed to the Infralo API
5
+ by a single daemon thread. The caller never waits for HTTP — enqueue()
6
+ returns immediately.
7
+
8
+ Flush schedule:
9
+ - Every `flush_interval` seconds (default 5 s).
10
+ - Early if the queue reaches `max_batch_size` (default 50).
11
+ - Final flush on process exit via atexit (called by Infralo.__init__).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import queue
18
+ import threading
19
+ from typing import TYPE_CHECKING
20
+
21
+ import httpx
22
+
23
+ if TYPE_CHECKING:
24
+ from infralo._models import ToolSpanData
25
+
26
+ logger = logging.getLogger("infralo.exporter")
27
+
28
+
29
+ class SpanExporter:
30
+ """
31
+ Background HTTP exporter for tool call spans.
32
+
33
+ Not intended for direct use — created and managed by the Infralo client.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: str,
39
+ endpoint: str,
40
+ flush_interval: float = 5.0,
41
+ max_batch_size: int = 50,
42
+ max_queue_size: int = 10000,
43
+ ) -> None:
44
+ self.api_key = api_key
45
+ clean_endpoint = endpoint.rstrip("/")
46
+ for suffix in ("/api/v1", "/v1"):
47
+ if clean_endpoint.endswith(suffix):
48
+ clean_endpoint = clean_endpoint[: -len(suffix)]
49
+ self.endpoint = clean_endpoint.rstrip("/")
50
+ self.flush_interval = flush_interval
51
+ self.max_batch_size = max_batch_size
52
+ self.max_queue_size = max_queue_size
53
+
54
+ self._queue: queue.Queue[ToolSpanData] = queue.Queue(maxsize=max_queue_size)
55
+ self._flush_event = threading.Event()
56
+ self._stop_event = threading.Event()
57
+ self._flush_lock = threading.Lock()
58
+
59
+ # Retry backoff state
60
+ self._current_interval = flush_interval
61
+ self._max_backoff = 60.0
62
+
63
+ self._thread = threading.Thread(
64
+ target=self._flush_loop,
65
+ name="infralo-exporter",
66
+ daemon=True, # dies with the process — atexit handles final flush
67
+ )
68
+ self._thread.start()
69
+
70
+ # ── Public API ──────────────────────────────────────────────────────────
71
+
72
+ def enqueue(self, span: "ToolSpanData") -> None:
73
+ """
74
+ Push a span onto the export queue. Always returns immediately.
75
+ Never raises — failures are logged and the span is re-queued.
76
+ """
77
+ try:
78
+ self._queue.put_nowait(span)
79
+ except queue.Full:
80
+ logger.warning("infralo: export queue full — dropping span %s", span.span_id)
81
+ return
82
+
83
+ if self._queue.qsize() >= self.max_batch_size:
84
+ self._flush_event.set() # wake the background thread early
85
+
86
+ def flush(self, timeout: float = 10.0) -> None:
87
+ """
88
+ Synchronously drain all pending spans from the queue.
89
+ Called automatically on process exit (atexit). Safe to call manually in tests.
90
+ """
91
+ self._do_flush()
92
+
93
+ def shutdown(self) -> None:
94
+ """Stop the background thread and do a final flush. Idempotent."""
95
+ self._stop_event.set()
96
+ self._flush_event.set()
97
+ self._thread.join(timeout=5.0)
98
+ self._do_flush()
99
+
100
+ # ── Background thread ───────────────────────────────────────────────────
101
+
102
+ def _flush_loop(self) -> None:
103
+ """Daemon thread: flush on interval or early-wake event with backoff on failure."""
104
+ while not self._stop_event.is_set():
105
+ self._flush_event.wait(timeout=self._current_interval)
106
+ self._flush_event.clear()
107
+ self._do_flush()
108
+ # Final drain after stop signal
109
+ self._do_flush()
110
+
111
+ def _do_flush(self) -> None:
112
+ """Drain up to max_batch_size spans and POST them to the API."""
113
+ with self._flush_lock:
114
+ batch: list[ToolSpanData] = []
115
+ try:
116
+ while len(batch) < self.max_batch_size:
117
+ batch.append(self._queue.get_nowait())
118
+ except queue.Empty:
119
+ pass
120
+
121
+ if not batch:
122
+ return
123
+
124
+ try:
125
+
126
+ resp = httpx.post(
127
+ f"{self.endpoint}/api/v1/traces/spans/tool-call",
128
+ headers={"Authorization": f"Bearer {self.api_key}"},
129
+ json=[s.to_dict() for s in batch],
130
+ timeout=10.0,
131
+ )
132
+ resp.raise_for_status()
133
+ logger.debug("infralo: flushed %d spans", len(batch))
134
+
135
+ # Reset backoff on success
136
+ self._current_interval = self.flush_interval
137
+
138
+ except Exception as exc:
139
+ logger.warning(
140
+ "infralo: failed to flush %d span(s): %s — re-enqueueing for retry",
141
+ len(batch),
142
+ exc,
143
+ )
144
+ # Apply exponential backoff
145
+ self._current_interval = min(self._current_interval * 2.0, self._max_backoff)
146
+
147
+ # Best-effort re-enqueue for next flush cycle
148
+ for span in batch:
149
+ try:
150
+ self._queue.put_nowait(span)
151
+ except queue.Full:
152
+ logger.warning(
153
+ "infralo: queue full during retry — dropping span %s", span.span_id
154
+ )
@@ -0,0 +1,23 @@
1
+ """
2
+ infralo — Agent framework integrations.
3
+
4
+ Each integration is a separate optional module. Install only the extras you
5
+ need; the core ``infralo`` package intentionally has no framework dependencies.
6
+
7
+ +-----------------+-------------------------------------+---------------------------+
8
+ | Framework | Install extra | Import path |
9
+ +-----------------+-------------------------------------+---------------------------+
10
+ | Agno | pip install infralo[agno] | infralo.integrations.agno |
11
+ | LangGraph/Chain | pip install infralo[langgraph] | infralo.integrations.langgraph |
12
+ | CrewAI | pip install infralo[crewai] | infralo.integrations.crewai |
13
+ | Google ADK | pip install infralo[google-adk] | infralo.integrations.google_adk |
14
+ +-----------------+-------------------------------------+---------------------------+
15
+
16
+ All integrations follow the same three-step pattern:
17
+
18
+ 1. Create the Infralo client once at module level.
19
+ 2. Create / register the framework-specific adapter.
20
+ 3. Wrap every agentic run with ``infralo.start_trace()``.
21
+
22
+ See the individual module docstrings and ``examples/`` for complete usage.
23
+ """