loopview-sdk 0.2.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.
- loopview_sdk/__init__.py +19 -0
- loopview_sdk/_live.py +136 -0
- loopview_sdk/connect.py +285 -0
- loopview_sdk-0.2.0.dist-info/METADATA +67 -0
- loopview_sdk-0.2.0.dist-info/RECORD +6 -0
- loopview_sdk-0.2.0.dist-info/WHEEL +4 -0
loopview_sdk/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""loopview-sdk: connect an agent to loopview.
|
|
2
|
+
|
|
3
|
+
import loopview_sdk
|
|
4
|
+
loopview_sdk.connect() # exporter, instrumentation, content, flush at exit
|
|
5
|
+
|
|
6
|
+
with loopview_sdk.agent("name"): # only for hand-written loops with no agent span
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
@loopview_sdk.tool # optional: exact timing and errors for your tools
|
|
10
|
+
def get_weather(city): ...
|
|
11
|
+
|
|
12
|
+
LiveStartProcessor, the start reporter connect() adds, can also be used on its own.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from loopview_sdk._live import LiveStartProcessor
|
|
16
|
+
from loopview_sdk.connect import agent, rank_instrumentors, connect, tool
|
|
17
|
+
|
|
18
|
+
__all__ = ["LiveStartProcessor", "agent", "rank_instrumentors", "connect", "tool"]
|
|
19
|
+
__version__ = "0.2.0"
|
loopview_sdk/_live.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""loopview-sdk: tell loopview when spans *start*, for true real-time views.
|
|
2
|
+
|
|
3
|
+
Standard OpenTelemetry exporters send a span only when it ends, so on its own
|
|
4
|
+
loopview sees a step only after it finished (it infers running parents from
|
|
5
|
+
their finished children). Add this processor next to your normal exporter and
|
|
6
|
+
loopview also hears about every span the moment it starts:
|
|
7
|
+
|
|
8
|
+
from loopview_sdk import LiveStartProcessor
|
|
9
|
+
provider.add_span_processor(LiveStartProcessor())
|
|
10
|
+
|
|
11
|
+
It only reports starts; your normal exporter still sends the finished spans.
|
|
12
|
+
Reports are batched every 50 ms and sent from a background thread, so the
|
|
13
|
+
agent never waits on loopview. If loopview isn't running, reports are dropped.
|
|
14
|
+
|
|
15
|
+
Dependencies: opentelemetry-sdk only (HTTP is done with the standard library).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import queue
|
|
21
|
+
import threading
|
|
22
|
+
import urllib.request
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from opentelemetry.context import Context
|
|
26
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
|
|
27
|
+
|
|
28
|
+
DEFAULT_ENDPOINT = "http://127.0.0.1:4318/v1/loopview/span-starts"
|
|
29
|
+
_KIND = {"INTERNAL": 1, "SERVER": 2, "CLIENT": 3, "PRODUCER": 4, "CONSUMER": 5}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LiveStartProcessor(SpanProcessor):
|
|
33
|
+
def __init__(self, endpoint: str | None = None, interval_s: float = 0.05) -> None:
|
|
34
|
+
self.endpoint = endpoint or os.environ.get("LOOPVIEW_STARTS_ENDPOINT", DEFAULT_ENDPOINT)
|
|
35
|
+
self.interval_s = interval_s
|
|
36
|
+
self._queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=10_000)
|
|
37
|
+
self._stop = threading.Event()
|
|
38
|
+
self._thread = threading.Thread(target=self._run, name="loopview-starts", daemon=True)
|
|
39
|
+
self._thread.start()
|
|
40
|
+
|
|
41
|
+
# --- SpanProcessor -----------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
|
44
|
+
try:
|
|
45
|
+
self._queue.put_nowait(_encode(span))
|
|
46
|
+
except queue.Full:
|
|
47
|
+
pass # never slow the agent down
|
|
48
|
+
|
|
49
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
50
|
+
pass # finished spans go through the user's normal exporter
|
|
51
|
+
|
|
52
|
+
def shutdown(self) -> None:
|
|
53
|
+
self._stop.set()
|
|
54
|
+
self._thread.join(timeout=2)
|
|
55
|
+
self._flush()
|
|
56
|
+
|
|
57
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
58
|
+
self._flush()
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# --- background sending --------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _run(self) -> None:
|
|
64
|
+
while not self._stop.wait(self.interval_s):
|
|
65
|
+
self._flush()
|
|
66
|
+
|
|
67
|
+
def _flush(self) -> None:
|
|
68
|
+
batch: list[dict[str, Any]] = []
|
|
69
|
+
while True:
|
|
70
|
+
try:
|
|
71
|
+
batch.append(self._queue.get_nowait())
|
|
72
|
+
except queue.Empty:
|
|
73
|
+
break
|
|
74
|
+
if not batch:
|
|
75
|
+
return
|
|
76
|
+
body = json.dumps(_request(batch)).encode()
|
|
77
|
+
request = urllib.request.Request(
|
|
78
|
+
self.endpoint, data=body, headers={"Content-Type": "application/json"}
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
urllib.request.urlopen(request, timeout=2).close()
|
|
82
|
+
except OSError:
|
|
83
|
+
pass # loopview not running: drop, like a best-effort exporter
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- OTLP/JSON encoding of a just-started span ---------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _encode(span: Span) -> dict[str, Any]:
|
|
90
|
+
ctx = span.get_span_context()
|
|
91
|
+
encoded: dict[str, Any] = {
|
|
92
|
+
"traceId": format(ctx.trace_id, "032x"),
|
|
93
|
+
"spanId": format(ctx.span_id, "016x"),
|
|
94
|
+
"name": span.name,
|
|
95
|
+
"kind": _KIND.get(span.kind.name, 0),
|
|
96
|
+
"startTimeUnixNano": str(span.start_time or 0),
|
|
97
|
+
"attributes": _kv(dict(span.attributes or {})),
|
|
98
|
+
}
|
|
99
|
+
if span.parent is not None:
|
|
100
|
+
encoded["parentSpanId"] = format(span.parent.span_id, "016x")
|
|
101
|
+
scope = span.instrumentation_scope
|
|
102
|
+
return {
|
|
103
|
+
"span": encoded,
|
|
104
|
+
"resource": dict(span.resource.attributes) if span.resource else {},
|
|
105
|
+
"scope": (scope.name, scope.version or "") if scope else ("", ""),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _request(batch: list[dict[str, Any]]) -> dict[str, Any]:
|
|
110
|
+
"""Group encoded spans by resource and scope into one ExportTraceServiceRequest."""
|
|
111
|
+
groups: dict[str, dict[str, Any]] = {}
|
|
112
|
+
for item in batch:
|
|
113
|
+
key = json.dumps([item["resource"], item["scope"]], sort_keys=True, default=str)
|
|
114
|
+
group = groups.setdefault(key, {
|
|
115
|
+
"resource": {"attributes": _kv(item["resource"])},
|
|
116
|
+
"scopeSpans": [{"scope": {"name": item["scope"][0], "version": item["scope"][1]},
|
|
117
|
+
"spans": []}],
|
|
118
|
+
})
|
|
119
|
+
group["scopeSpans"][0]["spans"].append(item["span"])
|
|
120
|
+
return {"resourceSpans": list(groups.values())}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _kv(attributes: dict[str, Any]) -> list[dict[str, Any]]:
|
|
124
|
+
return [{"key": k, "value": _value(v)} for k, v in attributes.items()]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _value(v: Any) -> dict[str, Any]:
|
|
128
|
+
if isinstance(v, bool):
|
|
129
|
+
return {"boolValue": v}
|
|
130
|
+
if isinstance(v, int):
|
|
131
|
+
return {"intValue": str(v)}
|
|
132
|
+
if isinstance(v, float):
|
|
133
|
+
return {"doubleValue": v}
|
|
134
|
+
if isinstance(v, (list, tuple)):
|
|
135
|
+
return {"arrayValue": {"values": [_value(x) for x in v]}}
|
|
136
|
+
return {"stringValue": str(v)}
|
loopview_sdk/connect.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Send an agent's traces to loopview with one call, whatever it is built with.
|
|
2
|
+
|
|
3
|
+
import loopview_sdk
|
|
4
|
+
loopview_sdk.connect()
|
|
5
|
+
|
|
6
|
+
It does what every agent needs, so nothing else is required in most cases:
|
|
7
|
+
- an OTLP/HTTP exporter to loopview, sending every 100 ms, and LiveStartProcessor
|
|
8
|
+
so steps light up when they start;
|
|
9
|
+
- the instrumentation of every agent framework or model SDK that is installed:
|
|
10
|
+
each OpenTelemetry instrumentor package registers itself (the
|
|
11
|
+
`opentelemetry_instrumentor` entry point), so they are found, not listed here.
|
|
12
|
+
When several cover the same library (OpenInference's and OpenTelemetry's for
|
|
13
|
+
the OpenAI SDK), OpenInference's is tried first: it records message content and
|
|
14
|
+
tool definitions. If it doesn't support the installed version of the library,
|
|
15
|
+
the next one is used, and a library none fits is reported. Pydantic AI
|
|
16
|
+
instruments itself and is turned on directly;
|
|
17
|
+
- message content capture, which some instrumentations leave off by default;
|
|
18
|
+
- a flush at exit, so a short script doesn't lose its last spans.
|
|
19
|
+
|
|
20
|
+
A hand-written loop on a model SDK has no span around it: each model call would be
|
|
21
|
+
a run of its own. Wrap the loop in `loopview_sdk.agent("name")` to make it one run.
|
|
22
|
+
Its tool calls are rebuilt from the conversation by loopview; decorate the tools
|
|
23
|
+
with `@loopview_sdk.tool` for exact timing and errors (all of them, or none).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import atexit
|
|
27
|
+
import functools
|
|
28
|
+
import inspect
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
34
|
+
from typing import Any, TypeVar, overload
|
|
35
|
+
|
|
36
|
+
from opentelemetry import trace
|
|
37
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
38
|
+
from opentelemetry.sdk.resources import Resource
|
|
39
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
40
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
41
|
+
|
|
42
|
+
from loopview_sdk._live import LiveStartProcessor
|
|
43
|
+
|
|
44
|
+
DEFAULT_URL = "http://127.0.0.1:4318"
|
|
45
|
+
|
|
46
|
+
# Instrumentors for agent frameworks and model SDKs, besides every OpenInference
|
|
47
|
+
# package: OpenTelemetry's own GenAI instrumentations and OpenLLMetry's register
|
|
48
|
+
# under these names. Other instrumentors (HTTP clients, databases) are left alone.
|
|
49
|
+
GENAI_NAMES = frozenset({
|
|
50
|
+
"openai", "anthropic", "langchain", "llama_index", "bedrock", "vertexai",
|
|
51
|
+
"google_genai", "mistralai", "groq", "cohere", "ollama", "crewai", "litellm",
|
|
52
|
+
"openai_agents", "haystack", "dspy", "smolagents", "autogen", "agno", "mcp",
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
# Content capture, for instrumentations that leave it off by default. Only set
|
|
56
|
+
# when the variable isn't set already: a user's choice always wins.
|
|
57
|
+
CONTENT_DEFAULTS = {
|
|
58
|
+
# OpenTelemetry's GenAI instrumentations (openai-v2, ...): content on spans,
|
|
59
|
+
# where loopview reads it, rather than as log events.
|
|
60
|
+
"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
|
|
61
|
+
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY",
|
|
62
|
+
# OpenLLMetry
|
|
63
|
+
"TRACELOOP_TRACE_CONTENT": "true",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def connect(
|
|
68
|
+
service_name: str | None = None,
|
|
69
|
+
url: str | None = None,
|
|
70
|
+
instrument: bool | Sequence[str] = True,
|
|
71
|
+
capture_content: bool = True,
|
|
72
|
+
live: bool = True,
|
|
73
|
+
provider: TracerProvider | None = None,
|
|
74
|
+
quiet: bool = False,
|
|
75
|
+
) -> TracerProvider:
|
|
76
|
+
"""Send this process's traces to loopview. Returns the TracerProvider used.
|
|
77
|
+
|
|
78
|
+
service_name: shown under each run (default: the script's name).
|
|
79
|
+
url: loopview's address. Default: LOOPVIEW_URL, else the standard
|
|
80
|
+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT if set, else http://127.0.0.1:4318.
|
|
81
|
+
instrument: True for every installed framework, False for none, or names
|
|
82
|
+
such as ["openai", "langchain"].
|
|
83
|
+
provider: a TracerProvider of your own to add loopview's processors to.
|
|
84
|
+
By default the global one is used if it is already an SDK
|
|
85
|
+
provider, otherwise one is created and made global.
|
|
86
|
+
"""
|
|
87
|
+
standard = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "").removesuffix("/v1/traces")
|
|
88
|
+
base = (url or os.environ.get("LOOPVIEW_URL") or standard or DEFAULT_URL).rstrip("/")
|
|
89
|
+
if capture_content:
|
|
90
|
+
for key, value in CONTENT_DEFAULTS.items():
|
|
91
|
+
os.environ.setdefault(key, value)
|
|
92
|
+
|
|
93
|
+
provider, owned = _provider(provider, service_name)
|
|
94
|
+
provider.add_span_processor(
|
|
95
|
+
BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{base}/v1/traces"), schedule_delay_millis=100)
|
|
96
|
+
)
|
|
97
|
+
if live:
|
|
98
|
+
provider.add_span_processor(LiveStartProcessor(endpoint=f"{base}/v1/loopview/span-starts"))
|
|
99
|
+
# Short scripts can end before the exporter's next batch: flush at exit.
|
|
100
|
+
atexit.register(provider.shutdown if owned else provider.force_flush)
|
|
101
|
+
|
|
102
|
+
instrumented, skipped = _instrument(provider, instrument) if instrument else ([], [])
|
|
103
|
+
if not quiet:
|
|
104
|
+
found = ", ".join(instrumented) or "nothing (no supported framework installed)"
|
|
105
|
+
print(f"loopview: sending traces to {base}; instrumented {found}")
|
|
106
|
+
for item in skipped:
|
|
107
|
+
print(f"loopview: could not instrument {item}")
|
|
108
|
+
return provider
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def agent(name: str, **attributes: Any) -> Iterator[trace.Span]:
|
|
113
|
+
"""A span around an agent's work, for loops that have none of their own.
|
|
114
|
+
|
|
115
|
+
with loopview_sdk.agent("weather_assistant"):
|
|
116
|
+
... # your loop: model calls, tool calls
|
|
117
|
+
|
|
118
|
+
Everything inside becomes one run, drawn as this agent. Works in async code too.
|
|
119
|
+
"""
|
|
120
|
+
tracer = trace.get_tracer("loopview_sdk")
|
|
121
|
+
with tracer.start_as_current_span(
|
|
122
|
+
f"invoke_agent {name}",
|
|
123
|
+
attributes={"gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": name, **attributes},
|
|
124
|
+
) as span:
|
|
125
|
+
yield span
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@overload
|
|
132
|
+
def tool(fn: F) -> F: ...
|
|
133
|
+
@overload
|
|
134
|
+
def tool(*, name: str | None = None) -> Callable[[F], F]: ...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def tool(fn: Callable[..., Any] | None = None, *, name: str | None = None) -> Any:
|
|
138
|
+
"""Record each call of a tool function: arguments, result, and errors.
|
|
139
|
+
|
|
140
|
+
@loopview_sdk.tool
|
|
141
|
+
def get_weather(city: str) -> dict: ...
|
|
142
|
+
|
|
143
|
+
Works on async functions too. An exception marks the call as failed and is
|
|
144
|
+
raised again unchanged. Optional: without it, loopview rebuilds tool calls
|
|
145
|
+
from the conversation, with approximate timing and fewer errors.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def decorate(f: Callable[..., Any]) -> Callable[..., Any]:
|
|
149
|
+
tool_name = name or f.__name__
|
|
150
|
+
signature = inspect.signature(f)
|
|
151
|
+
|
|
152
|
+
def start(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
153
|
+
try:
|
|
154
|
+
bound = signature.bind(*args, **kwargs)
|
|
155
|
+
arguments = _json(dict(bound.arguments))
|
|
156
|
+
except TypeError:
|
|
157
|
+
arguments = _json({"args": list(args), **kwargs})
|
|
158
|
+
return trace.get_tracer("loopview_sdk").start_as_current_span(
|
|
159
|
+
f"execute_tool {tool_name}",
|
|
160
|
+
attributes={
|
|
161
|
+
"gen_ai.operation.name": "execute_tool",
|
|
162
|
+
"gen_ai.tool.name": tool_name,
|
|
163
|
+
"gen_ai.tool.type": "function",
|
|
164
|
+
"gen_ai.tool.call.arguments": arguments,
|
|
165
|
+
},
|
|
166
|
+
record_exception=True,
|
|
167
|
+
set_status_on_exception=True,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if inspect.iscoroutinefunction(f):
|
|
171
|
+
|
|
172
|
+
@functools.wraps(f)
|
|
173
|
+
async def run_async(*args: Any, **kwargs: Any) -> Any:
|
|
174
|
+
with start(args, kwargs) as span:
|
|
175
|
+
result = await f(*args, **kwargs)
|
|
176
|
+
span.set_attribute("gen_ai.tool.call.result", _json(result))
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
return run_async
|
|
180
|
+
|
|
181
|
+
@functools.wraps(f)
|
|
182
|
+
def run(*args: Any, **kwargs: Any) -> Any:
|
|
183
|
+
with start(args, kwargs) as span:
|
|
184
|
+
result = f(*args, **kwargs)
|
|
185
|
+
span.set_attribute("gen_ai.tool.call.result", _json(result))
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
return run
|
|
189
|
+
|
|
190
|
+
return decorate(fn) if fn is not None else decorate
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _json(value: Any) -> str:
|
|
194
|
+
try:
|
|
195
|
+
return json.dumps(value, default=str)
|
|
196
|
+
except (TypeError, ValueError):
|
|
197
|
+
return str(value)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- helpers -------------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _provider(given: TracerProvider | None, service_name: str | None) -> tuple[TracerProvider, bool]:
|
|
204
|
+
"""The provider to use, and whether connect() created it."""
|
|
205
|
+
if given is not None:
|
|
206
|
+
return given, False
|
|
207
|
+
current = trace.get_tracer_provider()
|
|
208
|
+
if isinstance(current, TracerProvider):
|
|
209
|
+
return current, False
|
|
210
|
+
name = service_name or os.environ.get("OTEL_SERVICE_NAME") or _script_name()
|
|
211
|
+
provider = TracerProvider(resource=Resource.create({"service.name": name}))
|
|
212
|
+
trace.set_tracer_provider(provider)
|
|
213
|
+
return provider, True
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _script_name() -> str:
|
|
217
|
+
import __main__
|
|
218
|
+
|
|
219
|
+
path = getattr(__main__, "__file__", None)
|
|
220
|
+
return os.path.splitext(os.path.basename(path))[0] if path else "agent"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def rank_instrumentors(
|
|
224
|
+
points: Sequence[EntryPoint], only: Sequence[str] | None = None
|
|
225
|
+
) -> dict[str, list[EntryPoint]]:
|
|
226
|
+
"""The instrumentors for each GenAI library, best first: OpenInference's (it
|
|
227
|
+
records content and tool definitions), then the others in the order found."""
|
|
228
|
+
ranked: dict[str, list[EntryPoint]] = {}
|
|
229
|
+
for ep in points:
|
|
230
|
+
if not _is_genai(ep) or (only is not None and ep.name not in only):
|
|
231
|
+
continue
|
|
232
|
+
ranked.setdefault(ep.name, []).append(ep)
|
|
233
|
+
for candidates in ranked.values():
|
|
234
|
+
candidates.sort(key=lambda ep: not _is_openinference(ep)) # stable
|
|
235
|
+
return ranked
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _is_openinference(ep: EntryPoint) -> bool:
|
|
239
|
+
return bool(ep.dist and ep.dist.name.startswith("openinference-instrumentation-"))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_genai(ep: EntryPoint) -> bool:
|
|
243
|
+
return _is_openinference(ep) or ep.name in GENAI_NAMES
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _instrument(provider: TracerProvider, which: bool | Sequence[str]) -> tuple[list[str], list[str]]:
|
|
247
|
+
"""Instrument every installed GenAI library. Returns (instrumented, skipped), the
|
|
248
|
+
skipped ones with the reason: no instrumentor fits the installed version."""
|
|
249
|
+
only = None if which is True else list(which) # type: ignore[arg-type]
|
|
250
|
+
done: list[str] = []
|
|
251
|
+
skipped: list[str] = []
|
|
252
|
+
ranked = rank_instrumentors(list(entry_points(group="opentelemetry_instrumentor")), only)
|
|
253
|
+
for library, candidates in ranked.items():
|
|
254
|
+
reasons = []
|
|
255
|
+
for ep in candidates:
|
|
256
|
+
try:
|
|
257
|
+
instrumentor = ep.load()()
|
|
258
|
+
# The instrumentor's own check: is a version it supports installed?
|
|
259
|
+
conflict = instrumentor._check_dependency_conflicts()
|
|
260
|
+
if conflict is not None:
|
|
261
|
+
if getattr(conflict, "found", None): # installed, wrong version
|
|
262
|
+
reasons.append(f"{ep.dist.name if ep.dist else ep.name}: {conflict}")
|
|
263
|
+
continue
|
|
264
|
+
if not getattr(instrumentor, "is_instrumented_by_opentelemetry", False):
|
|
265
|
+
instrumentor.instrument(tracer_provider=provider)
|
|
266
|
+
done.append(library if _is_openinference(ep) else f"{library} ({ep.dist.name if ep.dist else ep.value})")
|
|
267
|
+
break
|
|
268
|
+
except Exception as exc: # a broken instrumentor must never stop the agent
|
|
269
|
+
reasons.append(f"{ep.dist.name if ep.dist else ep.name}: {exc}")
|
|
270
|
+
else:
|
|
271
|
+
if reasons:
|
|
272
|
+
skipped.append(f"{library} ({'; '.join(reasons)})")
|
|
273
|
+
if (only is None or "pydantic_ai" in only) and _instrument_pydantic_ai(provider):
|
|
274
|
+
done.append("pydantic_ai")
|
|
275
|
+
return done, skipped
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _instrument_pydantic_ai(provider: TracerProvider) -> bool:
|
|
279
|
+
try:
|
|
280
|
+
from pydantic_ai import Agent
|
|
281
|
+
from pydantic_ai.models.instrumented import InstrumentationSettings
|
|
282
|
+
except ImportError:
|
|
283
|
+
return False
|
|
284
|
+
Agent.instrument_all(InstrumentationSettings(tracer_provider=provider))
|
|
285
|
+
return True
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loopview-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Connect any agent to loopview in one call, and see steps the moment they start.
|
|
5
|
+
Keywords: ai-agents,llm,opentelemetry,observability,debugging,tracing
|
|
6
|
+
Author: YasmineZerai
|
|
7
|
+
Author-email: YasmineZerai <zeraiyasmine7@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Dist: opentelemetry-sdk>=1.20
|
|
18
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Project-URL: Homepage, https://github.com/YasmineZerai/loopview
|
|
21
|
+
Project-URL: Documentation, https://github.com/YasmineZerai/loopview#connect-your-agent
|
|
22
|
+
Project-URL: Issues, https://github.com/YasmineZerai/loopview/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# loopview-sdk
|
|
26
|
+
|
|
27
|
+
Connect any Python agent to [loopview](https://github.com/YasmineZerai/loopview) in one call.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import loopview_sdk
|
|
31
|
+
loopview_sdk.connect()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`connect()` does what every agent needs:
|
|
35
|
+
|
|
36
|
+
- sends traces to loopview (`LOOPVIEW_URL`, else `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,
|
|
37
|
+
else `http://127.0.0.1:4318`), every 100 ms;
|
|
38
|
+
- switches on the OpenTelemetry instrumentation of every installed agent framework or
|
|
39
|
+
model SDK. Instrumentor packages register themselves, so any OpenInference package,
|
|
40
|
+
OpenTelemetry's GenAI instrumentations and OpenLLMetry are found; when two cover the
|
|
41
|
+
same library, OpenInference's is used. Pydantic AI is switched on directly;
|
|
42
|
+
- turns on message content capture where an instrumentation leaves it off;
|
|
43
|
+
- reports span starts, so steps light up the moment they begin;
|
|
44
|
+
- flushes at exit, so short scripts don't lose their last spans.
|
|
45
|
+
|
|
46
|
+
Options: `connect(service_name=..., url=..., instrument=["openai"] or False,
|
|
47
|
+
capture_content=False, live=False, provider=my_provider, quiet=True)`.
|
|
48
|
+
|
|
49
|
+
## A loop you wrote yourself
|
|
50
|
+
|
|
51
|
+
Model SDK instrumentation records model calls only. Wrap your loop so it is one run,
|
|
52
|
+
drawn as an agent:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
with loopview_sdk.agent("weather_assistant"):
|
|
56
|
+
... # your loop
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Only the live start reports
|
|
60
|
+
|
|
61
|
+
`LiveStartProcessor` is the start reporter `connect()` adds, usable on its own next to
|
|
62
|
+
your own exporter:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from loopview_sdk import LiveStartProcessor
|
|
66
|
+
provider.add_span_processor(LiveStartProcessor())
|
|
67
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
loopview_sdk/__init__.py,sha256=dBJSePEGS8l7-YcOi4X40zrhjO7bTTYXz0_SyzXbrZI,701
|
|
2
|
+
loopview_sdk/_live.py,sha256=A3ZX9IdUidLupQr37tXBT3u6KpFmvkXjkHvM5242odY,5133
|
|
3
|
+
loopview_sdk/connect.py,sha256=6syDJyDDZ0QoMWFTz-OMI-dnYEhvh_z_TfxEFnk77ms,12036
|
|
4
|
+
loopview_sdk-0.2.0.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
|
|
5
|
+
loopview_sdk-0.2.0.dist-info/METADATA,sha256=M8pIo0A3jUwFjSKzbTl17BY2DC3jarbps2LvW1sv0K0,2591
|
|
6
|
+
loopview_sdk-0.2.0.dist-info/RECORD,,
|