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.
@@ -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
@@ -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
+ ```
@@ -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()