seerlens 0.2.0__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.
- seerlens-0.2.0/.gitignore +25 -0
- seerlens-0.2.0/PKG-INFO +41 -0
- seerlens-0.2.0/README.md +30 -0
- seerlens-0.2.0/example.py +18 -0
- seerlens-0.2.0/pyproject.toml +16 -0
- seerlens-0.2.0/seerlens/__init__.py +129 -0
- seerlens-0.2.0/test_seerlens.py +42 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
bin/
|
|
2
|
+
obj/
|
|
3
|
+
*.user
|
|
4
|
+
.vs/
|
|
5
|
+
|
|
6
|
+
# dashboard
|
|
7
|
+
node_modules/
|
|
8
|
+
dashboard/dist/
|
|
9
|
+
src/Seerlens.Collector/ui/
|
|
10
|
+
|
|
11
|
+
# local data
|
|
12
|
+
*.db
|
|
13
|
+
*.db-shm
|
|
14
|
+
*.db-wal
|
|
15
|
+
|
|
16
|
+
# local provider keys
|
|
17
|
+
.env.local
|
|
18
|
+
|
|
19
|
+
# build artifacts
|
|
20
|
+
nupkg/
|
|
21
|
+
dist/
|
|
22
|
+
|
|
23
|
+
# python
|
|
24
|
+
__pycache__/
|
|
25
|
+
*.pyc
|
seerlens-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: seerlens
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Send your Python app's LLM calls to Seerlens, the local DevTools for AI calls.
|
|
5
|
+
Project-URL: Homepage, https://github.com/eladser/seerlens
|
|
6
|
+
Author: Elad Sertshuk
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: ai,llm,observability,opentelemetry,tracing
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# seerlens (Python)
|
|
13
|
+
|
|
14
|
+
Send your Python app's LLM calls to [Seerlens](https://github.com/eladser/seerlens). Traces go out as OpenTelemetry GenAI spans, so they land in the same dashboard as the .NET ones.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import seerlens
|
|
18
|
+
|
|
19
|
+
seerlens.configure("http://localhost:5005")
|
|
20
|
+
|
|
21
|
+
with seerlens.trace("answer ticket", model="gpt-4o") as span:
|
|
22
|
+
reply = my_llm(prompt)
|
|
23
|
+
span.complete(prompt=prompt, completion=reply, input_tokens=40, output_tokens=12)
|
|
24
|
+
|
|
25
|
+
seerlens.flush() # before a short script exits
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or record a call you already made:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
seerlens.record(model="gpt-4o", prompt="hi", completion="hello",
|
|
32
|
+
input_tokens=10, output_tokens=5, duration_ms=820)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Traces are sent on a background thread. If the collector is down the trace is dropped; it never blocks or throws into your app.
|
|
36
|
+
|
|
37
|
+
No third-party dependencies. Run the example against a running collector:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
python example.py
|
|
41
|
+
```
|
seerlens-0.2.0/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# seerlens (Python)
|
|
2
|
+
|
|
3
|
+
Send your Python app's LLM calls to [Seerlens](https://github.com/eladser/seerlens). Traces go out as OpenTelemetry GenAI spans, so they land in the same dashboard as the .NET ones.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import seerlens
|
|
7
|
+
|
|
8
|
+
seerlens.configure("http://localhost:5005")
|
|
9
|
+
|
|
10
|
+
with seerlens.trace("answer ticket", model="gpt-4o") as span:
|
|
11
|
+
reply = my_llm(prompt)
|
|
12
|
+
span.complete(prompt=prompt, completion=reply, input_tokens=40, output_tokens=12)
|
|
13
|
+
|
|
14
|
+
seerlens.flush() # before a short script exits
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or record a call you already made:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
seerlens.record(model="gpt-4o", prompt="hi", completion="hello",
|
|
21
|
+
input_tokens=10, output_tokens=5, duration_ms=820)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Traces are sent on a background thread. If the collector is down the trace is dropped; it never blocks or throws into your app.
|
|
25
|
+
|
|
26
|
+
No third-party dependencies. Run the example against a running collector:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python example.py
|
|
30
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import seerlens
|
|
4
|
+
|
|
5
|
+
seerlens.configure(os.environ.get("SEERLENS_URL", "http://localhost:5005"))
|
|
6
|
+
|
|
7
|
+
# Pretend these came back from your LLM client.
|
|
8
|
+
with seerlens.trace("answer support ticket", model="gpt-4o") as span:
|
|
9
|
+
reply = "Your order shipped and arrives Thursday."
|
|
10
|
+
span.complete(prompt="Where is my order #5521?", completion=reply,
|
|
11
|
+
input_tokens=40, output_tokens=12)
|
|
12
|
+
|
|
13
|
+
seerlens.record(model="claude-3-5-sonnet", prompt="Summarize the update.",
|
|
14
|
+
completion="Numbers up, churn down.", input_tokens=120,
|
|
15
|
+
output_tokens=18, duration_ms=430)
|
|
16
|
+
|
|
17
|
+
seerlens.flush()
|
|
18
|
+
print("sent traces to Seerlens")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "seerlens"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Send your Python app's LLM calls to Seerlens, the local DevTools for AI calls."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Elad Sertshuk" }]
|
|
9
|
+
keywords = ["llm", "ai", "observability", "tracing", "opentelemetry"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/eladser/seerlens"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Send your Python app's LLM calls to Seerlens.
|
|
2
|
+
|
|
3
|
+
import seerlens
|
|
4
|
+
seerlens.configure("http://localhost:5005")
|
|
5
|
+
|
|
6
|
+
with seerlens.trace("answer ticket", model="gpt-4o") as span:
|
|
7
|
+
reply = my_llm(prompt)
|
|
8
|
+
span.complete(prompt=prompt, completion=reply, input_tokens=40, output_tokens=12)
|
|
9
|
+
|
|
10
|
+
seerlens.flush() # before a short script exits
|
|
11
|
+
|
|
12
|
+
Traces are sent as OpenTelemetry GenAI spans, on a background thread. If the
|
|
13
|
+
collector is down the trace is dropped; it never blocks or breaks your app.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import urllib.request
|
|
21
|
+
|
|
22
|
+
__all__ = ["configure", "record", "trace", "flush"]
|
|
23
|
+
|
|
24
|
+
_endpoint = None
|
|
25
|
+
_threads = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def configure(collector_url):
|
|
29
|
+
global _endpoint
|
|
30
|
+
_endpoint = collector_url.rstrip("/") + "/v1/traces"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def record(model, prompt="", completion="", input_tokens=None, output_tokens=None,
|
|
34
|
+
duration_ms=0.0, system=None, name=None):
|
|
35
|
+
"""Record one finished LLM call."""
|
|
36
|
+
end = time.time_ns()
|
|
37
|
+
start = end - int(duration_ms * 1_000_000)
|
|
38
|
+
span = {
|
|
39
|
+
"traceId": _hexid(16),
|
|
40
|
+
"spanId": _hexid(8),
|
|
41
|
+
"parentSpanId": "",
|
|
42
|
+
"name": name or f"chat: {model}",
|
|
43
|
+
"startTimeUnixNano": str(start),
|
|
44
|
+
"endTimeUnixNano": str(end),
|
|
45
|
+
"attributes": _attrs(model, prompt, completion, input_tokens, output_tokens, system),
|
|
46
|
+
"status": {"code": 1},
|
|
47
|
+
}
|
|
48
|
+
_send({"resourceSpans": [{"scopeSpans": [{"spans": [span]}]}]})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class trace:
|
|
52
|
+
"""Context manager that times a call and records it on exit."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, name, model, system=None):
|
|
55
|
+
self._name = name
|
|
56
|
+
self._model = model
|
|
57
|
+
self._system = system
|
|
58
|
+
self._prompt = ""
|
|
59
|
+
self._completion = ""
|
|
60
|
+
self._in = None
|
|
61
|
+
self._out = None
|
|
62
|
+
|
|
63
|
+
def __enter__(self):
|
|
64
|
+
self._t0 = time.perf_counter()
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def complete(self, prompt="", completion="", input_tokens=None, output_tokens=None):
|
|
68
|
+
self._prompt = prompt
|
|
69
|
+
self._completion = completion
|
|
70
|
+
self._in = input_tokens
|
|
71
|
+
self._out = output_tokens
|
|
72
|
+
|
|
73
|
+
def __exit__(self, *exc):
|
|
74
|
+
ms = (time.perf_counter() - self._t0) * 1000
|
|
75
|
+
record(self._model, self._prompt, self._completion, self._in, self._out,
|
|
76
|
+
ms, self._system, self._name)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def flush(timeout=3.0):
|
|
81
|
+
"""Wait for any in-flight traces to finish sending. Call before a short script exits."""
|
|
82
|
+
for t in list(_threads):
|
|
83
|
+
t.join(timeout)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _send(payload):
|
|
87
|
+
if not _endpoint:
|
|
88
|
+
return
|
|
89
|
+
t = threading.Thread(target=_post, args=(payload,), daemon=True)
|
|
90
|
+
_threads.append(t)
|
|
91
|
+
t.start()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _post(payload):
|
|
95
|
+
try:
|
|
96
|
+
data = json.dumps(payload).encode()
|
|
97
|
+
req = urllib.request.Request(_endpoint, data=data, headers={"Content-Type": "application/json"})
|
|
98
|
+
urllib.request.urlopen(req, timeout=5).close()
|
|
99
|
+
except Exception:
|
|
100
|
+
pass # never break the host app
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _attrs(model, prompt, completion, in_tokens, out_tokens, system):
|
|
104
|
+
pairs = [
|
|
105
|
+
("gen_ai.system", system or _provider(model)),
|
|
106
|
+
("gen_ai.request.model", model),
|
|
107
|
+
("gen_ai.prompt", prompt),
|
|
108
|
+
("gen_ai.completion", completion),
|
|
109
|
+
]
|
|
110
|
+
out = [{"key": k, "value": {"stringValue": str(v)}} for k, v in pairs if v]
|
|
111
|
+
for k, v in (("gen_ai.usage.input_tokens", in_tokens), ("gen_ai.usage.output_tokens", out_tokens)):
|
|
112
|
+
if v is not None:
|
|
113
|
+
out.append({"key": k, "value": {"intValue": str(v)}})
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _provider(model):
|
|
118
|
+
m = (model or "").lower()
|
|
119
|
+
if m.startswith(("gpt", "o1", "o3")):
|
|
120
|
+
return "openai"
|
|
121
|
+
if "claude" in m:
|
|
122
|
+
return "anthropic"
|
|
123
|
+
if "gemini" in m:
|
|
124
|
+
return "google"
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _hexid(n):
|
|
129
|
+
return os.urandom(n).hex()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
import seerlens
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SeerlensTests(unittest.TestCase):
|
|
7
|
+
def setUp(self):
|
|
8
|
+
self.sent = []
|
|
9
|
+
seerlens._send = lambda payload: self.sent.append(payload) # bypass the network
|
|
10
|
+
|
|
11
|
+
def _span(self):
|
|
12
|
+
return self.sent[-1]["resourceSpans"][0]["scopeSpans"][0]["spans"][0]
|
|
13
|
+
|
|
14
|
+
def _attrs(self):
|
|
15
|
+
return {a["key"]: a["value"] for a in self._span()["attributes"]}
|
|
16
|
+
|
|
17
|
+
def test_record_builds_a_genai_span(self):
|
|
18
|
+
seerlens.record(model="gpt-4o", prompt="hi", completion="hello",
|
|
19
|
+
input_tokens=10, output_tokens=5, duration_ms=200)
|
|
20
|
+
|
|
21
|
+
attrs = self._attrs()
|
|
22
|
+
self.assertEqual(attrs["gen_ai.request.model"]["stringValue"], "gpt-4o")
|
|
23
|
+
self.assertEqual(attrs["gen_ai.system"]["stringValue"], "openai")
|
|
24
|
+
self.assertEqual(attrs["gen_ai.usage.input_tokens"]["intValue"], "10")
|
|
25
|
+
self.assertEqual(attrs["gen_ai.prompt"]["stringValue"], "hi")
|
|
26
|
+
|
|
27
|
+
def test_duration_is_reflected_in_span_times(self):
|
|
28
|
+
seerlens.record(model="gpt-4o", duration_ms=200)
|
|
29
|
+
span = self._span()
|
|
30
|
+
elapsed_ns = int(span["endTimeUnixNano"]) - int(span["startTimeUnixNano"])
|
|
31
|
+
self.assertEqual(elapsed_ns, 200 * 1_000_000)
|
|
32
|
+
|
|
33
|
+
def test_trace_context_manager_records_with_inferred_provider(self):
|
|
34
|
+
with seerlens.trace("ticket", model="claude-3-5-sonnet") as span:
|
|
35
|
+
span.complete(prompt="q", completion="a", input_tokens=3, output_tokens=2)
|
|
36
|
+
|
|
37
|
+
self.assertEqual(self._span()["name"], "ticket")
|
|
38
|
+
self.assertEqual(self._attrs()["gen_ai.system"]["stringValue"], "anthropic")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
unittest.main()
|