windhover 0.7.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.
- windhover/__init__.py +5 -0
- windhover/config.py +32 -0
- windhover/demo_graph.py +48 -0
- windhover/extract.py +103 -0
- windhover/pricing.json +16 -0
- windhover/server.py +333 -0
- windhover/static/icon.svg +8 -0
- windhover/static/index.html +843 -0
- windhover/static/manifest.json +18 -0
- windhover/static/vendor/cytoscape-dagre.js +397 -0
- windhover/static/vendor/cytoscape.min.js +32 -0
- windhover/static/vendor/dagre.min.js +3809 -0
- windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
- windhover/store.py +347 -0
- windhover/tracer.py +325 -0
- windhover-0.7.0.dist-info/METADATA +111 -0
- windhover-0.7.0.dist-info/RECORD +22 -0
- windhover-0.7.0.dist-info/WHEEL +4 -0
- windhover-0.7.0.dist-info/entry_points.txt +2 -0
- windhover-0.7.0.dist-info/licenses/LICENSE +21 -0
windhover/tracer.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Windhover tracer — one LangChain callback, two sinks.
|
|
2
|
+
|
|
3
|
+
`SpanBuilder` turns LangGraph/LangChain callbacks into a run + span tree and hands
|
|
4
|
+
each event to a `sink`. In-process runs use a DB sink; external apps use
|
|
5
|
+
`WindhoverTracer` (HTTP sink) — identical span logic, so the trace looks the same
|
|
6
|
+
wherever it came from.
|
|
7
|
+
|
|
8
|
+
Callback realities handled:
|
|
9
|
+
* LangGraph exposes the node name only on `on_chain_start` (metadata.langgraph_node),
|
|
10
|
+
never on end → we map langchain run_id -> our span.
|
|
11
|
+
* Token usage lives in either `llm_output.token_usage` (OpenAI-style) or a
|
|
12
|
+
generation's `usage_metadata` (input_tokens/output_tokens) — we read both.
|
|
13
|
+
* Parent linkage uses LangChain's parent_run_id.
|
|
14
|
+
Best-effort: a sink error never propagates into the user's graph.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
import json, time, uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Optional
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
23
|
+
except Exception: # pragma: no cover - allows import without langchain
|
|
24
|
+
BaseCallbackHandler = object # type: ignore
|
|
25
|
+
|
|
26
|
+
_PRICING: Optional[dict] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_pricing() -> dict:
|
|
30
|
+
global _PRICING
|
|
31
|
+
if _PRICING is None:
|
|
32
|
+
try:
|
|
33
|
+
p = Path(__file__).parent / "pricing.json"
|
|
34
|
+
_PRICING = {k: v for k, v in json.loads(p.read_text()).items()
|
|
35
|
+
if not k.startswith("_")}
|
|
36
|
+
except Exception:
|
|
37
|
+
_PRICING = {}
|
|
38
|
+
return _PRICING
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cost_of(model: Optional[str], pt: Optional[int], ct: Optional[int]) -> Optional[float]:
|
|
42
|
+
if not model or (pt is None and ct is None):
|
|
43
|
+
return None
|
|
44
|
+
table = _load_pricing()
|
|
45
|
+
best = max((k for k in table if model.startswith(k)), key=len, default=None)
|
|
46
|
+
if not best:
|
|
47
|
+
return None
|
|
48
|
+
rate = table[best]
|
|
49
|
+
return round((pt or 0) / 1e6 * rate["input"] + (ct or 0) / 1e6 * rate["output"], 6)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _err_text(error: Any) -> str:
|
|
53
|
+
"""Full traceback when we have one — the UI maps 'File …, line N' frames
|
|
54
|
+
onto node source so you can see exactly where a run broke."""
|
|
55
|
+
try:
|
|
56
|
+
if isinstance(error, BaseException) and error.__traceback__ is not None:
|
|
57
|
+
import traceback
|
|
58
|
+
return "".join(traceback.format_exception(
|
|
59
|
+
type(error), error, error.__traceback__))[-4000:]
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return str(error)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _trunc(v: Any, n: int = 4000) -> Any:
|
|
66
|
+
s = json.dumps(v, default=str)
|
|
67
|
+
return json.loads(s) if len(s) <= n else s[:n] + "…"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _model_name(serialized: dict, kw: dict) -> str:
|
|
71
|
+
inv = kw.get("invocation_params") or {}
|
|
72
|
+
for src in (inv, (serialized or {}).get("kwargs", {}), serialized or {}):
|
|
73
|
+
for key in ("model", "model_name", "model_id", "deployment_name"):
|
|
74
|
+
if src.get(key):
|
|
75
|
+
return str(src[key])
|
|
76
|
+
ids = (serialized or {}).get("id") or []
|
|
77
|
+
return ids[-1] if ids else "llm"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _usage(response: Any) -> tuple[Optional[int], Optional[int]]:
|
|
81
|
+
# OpenAI-style aggregate
|
|
82
|
+
out = getattr(response, "llm_output", None) or {}
|
|
83
|
+
for key in ("token_usage", "usage"):
|
|
84
|
+
u = out.get(key) if isinstance(out, dict) else None
|
|
85
|
+
if u:
|
|
86
|
+
return (u.get("prompt_tokens") or u.get("input_tokens"),
|
|
87
|
+
u.get("completion_tokens") or u.get("output_tokens"))
|
|
88
|
+
# per-generation usage_metadata (chat models)
|
|
89
|
+
try:
|
|
90
|
+
for gens in response.generations:
|
|
91
|
+
for g in gens:
|
|
92
|
+
um = getattr(getattr(g, "message", None), "usage_metadata", None)
|
|
93
|
+
if um:
|
|
94
|
+
return um.get("input_tokens"), um.get("output_tokens")
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
return None, None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _gen_text(response: Any) -> str:
|
|
101
|
+
try:
|
|
102
|
+
parts = []
|
|
103
|
+
for gens in response.generations:
|
|
104
|
+
for g in gens:
|
|
105
|
+
msg = getattr(g, "message", None)
|
|
106
|
+
text = getattr(g, "text", "") or str(getattr(msg, "content", "") or "")
|
|
107
|
+
if not text and msg is not None:
|
|
108
|
+
# structured-output / function-calling: content is empty,
|
|
109
|
+
# the payload lives in the tool calls
|
|
110
|
+
tc = (getattr(msg, "tool_calls", None) or
|
|
111
|
+
(getattr(msg, "additional_kwargs", None) or {}).get("tool_calls"))
|
|
112
|
+
if tc:
|
|
113
|
+
text = json.dumps(tc, default=str)
|
|
114
|
+
parts.append(text)
|
|
115
|
+
return "\n".join(p for p in parts if p)
|
|
116
|
+
except Exception:
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SpanBuilder(BaseCallbackHandler):
|
|
121
|
+
def __init__(self, sink: Callable[[dict], None], run_name: str = "external",
|
|
122
|
+
session: Optional[str] = None, tags: Optional[list] = None):
|
|
123
|
+
self.sink = sink
|
|
124
|
+
self.run_name = run_name
|
|
125
|
+
self.session = session
|
|
126
|
+
self.tags = tags
|
|
127
|
+
self.run_id = uuid.uuid4().hex[:12]
|
|
128
|
+
self._root = None # langchain run_id of the graph root
|
|
129
|
+
self._t0: Optional[float] = None
|
|
130
|
+
self._open: dict[str, dict] = {} # langchain run_id -> pending span
|
|
131
|
+
self._span_of: dict[str, str] = {} # langchain run_id -> our span id (for parent links)
|
|
132
|
+
self._seq = 0
|
|
133
|
+
|
|
134
|
+
# -- infra --
|
|
135
|
+
def _emit(self, ev: dict) -> None:
|
|
136
|
+
try:
|
|
137
|
+
self.sink(ev)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass # observability must never break the graph
|
|
140
|
+
|
|
141
|
+
def _rel(self, t: float) -> int:
|
|
142
|
+
return int((t - (self._t0 or t)) * 1000)
|
|
143
|
+
|
|
144
|
+
def _finish_span(self, info: dict, *, output=None, status="ok", error=None,
|
|
145
|
+
model=None, pt=None, ct=None):
|
|
146
|
+
now = time.time()
|
|
147
|
+
sid = info["span_id"]
|
|
148
|
+
self._emit({"kind": "span", "id": sid, "run_id": self.run_id,
|
|
149
|
+
"parent_id": self._span_of.get(info.get("parent")),
|
|
150
|
+
"seq": self._seq, "type": info["type"], "name": info["name"],
|
|
151
|
+
"status": status, "started_ms": int(info["t"] * 1000),
|
|
152
|
+
"ended_ms": int(now * 1000), "offset_ms": self._rel(info["t"]),
|
|
153
|
+
"dur_ms": int((now - info["t"]) * 1000),
|
|
154
|
+
"input": info.get("input"), "output": output, "model": model,
|
|
155
|
+
"prompt_tokens": pt, "completion_tokens": ct,
|
|
156
|
+
"cost_usd": cost_of(model, pt, ct), "error": error})
|
|
157
|
+
self._seq += 1
|
|
158
|
+
|
|
159
|
+
# -- chains / nodes --
|
|
160
|
+
_INTERNAL_TAG_PREFIXES = ("graph:", "langsmith:", "seq:", "langgraph_")
|
|
161
|
+
|
|
162
|
+
def on_chain_start(self, serialized, inputs, *, run_id=None, parent_run_id=None,
|
|
163
|
+
metadata=None, tags=None, **kw):
|
|
164
|
+
node = (metadata or {}).get("langgraph_node")
|
|
165
|
+
if parent_run_id is None and self._root is None:
|
|
166
|
+
self._root = run_id
|
|
167
|
+
self._t0 = time.time()
|
|
168
|
+
# standard LangChain idioms work from ANY app, no constructor needed:
|
|
169
|
+
# config={"metadata": {"windhover_session": ..., "windhover_tags": [...]},
|
|
170
|
+
# "tags": [...]}
|
|
171
|
+
md = metadata or {}
|
|
172
|
+
if md.get("windhover_session"):
|
|
173
|
+
self.session = str(md["windhover_session"])
|
|
174
|
+
user_tags = [t for t in (tags or [])
|
|
175
|
+
if not str(t).startswith(self._INTERNAL_TAG_PREFIXES)]
|
|
176
|
+
extra = md.get("windhover_tags") or []
|
|
177
|
+
merged = list(dict.fromkeys(
|
|
178
|
+
[*(self.tags or []), *map(str, extra), *map(str, user_tags)]))
|
|
179
|
+
self.tags = merged or None
|
|
180
|
+
self._emit({"kind": "run_open", "run_id": self.run_id, "graph": self.run_name,
|
|
181
|
+
"input": _trunc(inputs), "started_ms": int(self._t0 * 1000),
|
|
182
|
+
"session": self.session, "tags": self.tags})
|
|
183
|
+
if node:
|
|
184
|
+
sid = uuid.uuid4().hex[:12]
|
|
185
|
+
self._span_of[run_id] = sid
|
|
186
|
+
self._open[run_id] = {"span_id": sid, "type": "node", "name": node,
|
|
187
|
+
"t": time.time(), "parent": parent_run_id,
|
|
188
|
+
"input": None}
|
|
189
|
+
|
|
190
|
+
def on_chain_end(self, outputs, *, run_id=None, **kw):
|
|
191
|
+
info = self._open.pop(run_id, None)
|
|
192
|
+
if info:
|
|
193
|
+
self._finish_span(info, output=_trunc(outputs))
|
|
194
|
+
elif run_id == self._root:
|
|
195
|
+
# LangGraph human-in-the-loop: a paused graph surfaces __interrupt__
|
|
196
|
+
# in its final output — that's a pause awaiting Command(resume=...),
|
|
197
|
+
# not a completion.
|
|
198
|
+
interrupted = isinstance(outputs, dict) and "__interrupt__" in outputs
|
|
199
|
+
if interrupted:
|
|
200
|
+
self._finish_span(
|
|
201
|
+
{"span_id": uuid.uuid4().hex[:12], "type": "interrupt",
|
|
202
|
+
"name": "interrupt", "t": time.time(), "parent": None,
|
|
203
|
+
"input": None},
|
|
204
|
+
output=_trunc(outputs.get("__interrupt__")))
|
|
205
|
+
self._emit({"kind": "run_close", "run_id": self.run_id,
|
|
206
|
+
"status": "interrupted" if interrupted else "done",
|
|
207
|
+
"ended_ms": int(time.time() * 1000)})
|
|
208
|
+
|
|
209
|
+
def on_chain_error(self, error, *, run_id=None, **kw):
|
|
210
|
+
info = self._open.pop(run_id, None)
|
|
211
|
+
err = _err_text(error)
|
|
212
|
+
if info:
|
|
213
|
+
self._finish_span(info, status="error", error=err)
|
|
214
|
+
if run_id == self._root:
|
|
215
|
+
self._emit({"kind": "run_close", "run_id": self.run_id, "status": "error",
|
|
216
|
+
"ended_ms": int(time.time() * 1000), "error": err})
|
|
217
|
+
|
|
218
|
+
# -- LLMs --
|
|
219
|
+
def _llm_start(self, serialized, prompt, run_id, parent_run_id, kw):
|
|
220
|
+
sid = uuid.uuid4().hex[:12]
|
|
221
|
+
self._span_of[run_id] = sid
|
|
222
|
+
self._open[run_id] = {"span_id": sid, "type": "llm",
|
|
223
|
+
"name": _model_name(serialized, kw), "t": time.time(),
|
|
224
|
+
"parent": parent_run_id, "input": _trunc(prompt)}
|
|
225
|
+
|
|
226
|
+
def on_llm_start(self, serialized, prompts, *, run_id=None, parent_run_id=None, **kw):
|
|
227
|
+
self._llm_start(serialized, prompts, run_id, parent_run_id, kw)
|
|
228
|
+
|
|
229
|
+
def on_chat_model_start(self, serialized, messages, *, run_id=None, parent_run_id=None, **kw):
|
|
230
|
+
flat = [[getattr(m, "content", str(m)) for m in conv] for conv in messages]
|
|
231
|
+
self._llm_start(serialized, flat, run_id, parent_run_id, kw)
|
|
232
|
+
|
|
233
|
+
def on_llm_end(self, response, *, run_id=None, **kw):
|
|
234
|
+
info = self._open.pop(run_id, None)
|
|
235
|
+
if not info:
|
|
236
|
+
return
|
|
237
|
+
pt, ct = _usage(response)
|
|
238
|
+
self._finish_span(info, output=_gen_text(response)[:4000],
|
|
239
|
+
model=info["name"], pt=pt, ct=ct)
|
|
240
|
+
|
|
241
|
+
def on_llm_error(self, error, *, run_id=None, **kw):
|
|
242
|
+
info = self._open.pop(run_id, None)
|
|
243
|
+
if info:
|
|
244
|
+
self._finish_span(info, status="error", error=_err_text(error), model=info["name"])
|
|
245
|
+
|
|
246
|
+
# -- retrievers (LangChain RAG) --
|
|
247
|
+
def on_retriever_start(self, serialized, query, *, run_id=None, parent_run_id=None, **kw):
|
|
248
|
+
sid = uuid.uuid4().hex[:12]
|
|
249
|
+
self._span_of[run_id] = sid
|
|
250
|
+
self._open[run_id] = {"span_id": sid, "type": "retriever",
|
|
251
|
+
"name": (serialized or {}).get("name") or "retriever",
|
|
252
|
+
"t": time.time(), "parent": parent_run_id,
|
|
253
|
+
"input": _trunc(query)}
|
|
254
|
+
|
|
255
|
+
def on_retriever_end(self, documents, *, run_id=None, **kw):
|
|
256
|
+
info = self._open.pop(run_id, None)
|
|
257
|
+
if not info:
|
|
258
|
+
return
|
|
259
|
+
docs = list(documents or [])
|
|
260
|
+
preview = [{"content": str(getattr(d, "page_content", d))[:300],
|
|
261
|
+
"metadata": _trunc(getattr(d, "metadata", None), 500)}
|
|
262
|
+
for d in docs[:8]]
|
|
263
|
+
self._finish_span(info, output=_trunc({"count": len(docs), "documents": preview}))
|
|
264
|
+
|
|
265
|
+
def on_retriever_error(self, error, *, run_id=None, **kw):
|
|
266
|
+
info = self._open.pop(run_id, None)
|
|
267
|
+
if info:
|
|
268
|
+
self._finish_span(info, status="error", error=_err_text(error))
|
|
269
|
+
|
|
270
|
+
# -- tools --
|
|
271
|
+
def on_tool_start(self, serialized, input_str, *, run_id=None, parent_run_id=None, **kw):
|
|
272
|
+
sid = uuid.uuid4().hex[:12]
|
|
273
|
+
self._span_of[run_id] = sid
|
|
274
|
+
self._open[run_id] = {"span_id": sid, "type": "tool",
|
|
275
|
+
"name": (serialized or {}).get("name", "tool"),
|
|
276
|
+
"t": time.time(), "parent": parent_run_id,
|
|
277
|
+
"input": _trunc(input_str)}
|
|
278
|
+
|
|
279
|
+
def on_tool_end(self, output, *, run_id=None, **kw):
|
|
280
|
+
info = self._open.pop(run_id, None)
|
|
281
|
+
if info:
|
|
282
|
+
self._finish_span(info, output=_trunc(str(output)[:4000]))
|
|
283
|
+
|
|
284
|
+
def on_tool_error(self, error, *, run_id=None, **kw):
|
|
285
|
+
info = self._open.pop(run_id, None)
|
|
286
|
+
if info:
|
|
287
|
+
self._finish_span(info, status="error", error=_err_text(error))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def apply_to_store(store, ev: dict, source: str = "ui") -> None:
|
|
291
|
+
"""Route one tracer event into the store. Shared by the local sink and the
|
|
292
|
+
HTTP ingest endpoint so both persist identically. (Events carry `run_id`;
|
|
293
|
+
the runs table keys on `id` — translate here.)"""
|
|
294
|
+
kind = ev.get("kind")
|
|
295
|
+
if kind == "run_open":
|
|
296
|
+
store.open_run({"id": ev["run_id"], "graph": ev.get("graph"), "source": source,
|
|
297
|
+
"session": ev.get("session"), "tags": ev.get("tags"),
|
|
298
|
+
"input": ev.get("input"), "started_ms": ev["started_ms"]})
|
|
299
|
+
elif kind == "span":
|
|
300
|
+
store.add_span(ev)
|
|
301
|
+
elif kind == "run_close":
|
|
302
|
+
store.close_run(ev["run_id"], ev.get("status", "done"),
|
|
303
|
+
ev.get("ended_ms"), ev.get("error"))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def db_sink(store) -> Callable[[dict], None]:
|
|
307
|
+
return lambda ev: apply_to_store(store, ev, source="ui")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def http_sink(base_url: str) -> Callable[[dict], None]:
|
|
311
|
+
import urllib.request
|
|
312
|
+
url = base_url.rstrip("/") + "/api/ingest"
|
|
313
|
+
|
|
314
|
+
def sink(ev: dict) -> None:
|
|
315
|
+
req = urllib.request.Request(url, data=json.dumps(ev).encode(),
|
|
316
|
+
headers={"Content-Type": "application/json"})
|
|
317
|
+
urllib.request.urlopen(req, timeout=3)
|
|
318
|
+
return sink
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class WindhoverTracer(SpanBuilder):
|
|
322
|
+
"""Drop into any app: config={"callbacks": [WindhoverTracer("http://host:8090")]}."""
|
|
323
|
+
def __init__(self, base_url: str, name: str = "external",
|
|
324
|
+
session: Optional[str] = None, tags: Optional[list] = None):
|
|
325
|
+
super().__init__(http_sink(base_url), run_name=name, session=session, tags=tags)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: windhover
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
|
|
5
|
+
Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
|
|
6
|
+
Author: justfeltlikerunning
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,langchain,langgraph,llm,observability,tracing
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: fastapi>=0.110
|
|
12
|
+
Requires-Dist: uvicorn>=0.29
|
|
13
|
+
Provides-Extra: demo
|
|
14
|
+
Requires-Dist: langgraph>=0.2; extra == 'demo'
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: langgraph>=0.2; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
<p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
|
|
21
|
+
<h1 align="center">Windhover</h1>
|
|
22
|
+
|
|
23
|
+
> *Windhover* — the old poetic name for the kestrel, the falcon that hangs motionless
|
|
24
|
+
> in the wind, watching everything below. This tool does the same for your agent graphs.
|
|
25
|
+
|
|
26
|
+
**Self-hosted, mobile-friendly observability for [LangGraph](https://github.com/langchain-ai/langgraph).**
|
|
27
|
+
Trace depth like LangSmith (LLM prompts, tokens, cost, latency — plus retrievers and
|
|
28
|
+
human-in-the-loop interrupts), run history, a timing waterfall, per-node stats, error
|
|
29
|
+
forensics down to the throwing source line — and a **living graph view** that auto-updates
|
|
30
|
+
when your code's topology changes. Point it at any compiled graph, or trace runs in from
|
|
31
|
+
your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP + SSE, MIT.
|
|
32
|
+
|
|
33
|
+
> Nothing about your graph's domain is baked in. Topology, the input form, and run
|
|
34
|
+
> outputs all come from the graph itself. Windhover observes — it never edits your graph.
|
|
35
|
+
|
|
36
|
+
| Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
|
|
37
|
+
|---|---|
|
|
38
|
+
|  |  |
|
|
39
|
+
|
|
40
|
+
| Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
|
|
41
|
+
|---|---|
|
|
42
|
+
|  |  |
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
```bash
|
|
46
|
+
pip install fastapi uvicorn langgraph
|
|
47
|
+
WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server # -> :8090
|
|
48
|
+
```
|
|
49
|
+
Open `http://<host>:8090`. **New run** (input pre-filled from the graph's schema) →
|
|
50
|
+
watch it execute → **Runs** for history, span trees, and replay → **Stats** for cost/latency.
|
|
51
|
+
Edit the graph file while it runs and the canvas updates itself.
|
|
52
|
+
|
|
53
|
+
Your own graph: `WINDHOVER_GRAPH="myapp.graphs:g" WINDHOVER_GRAPH_DIR=/path python -m windhover.server`
|
|
54
|
+
|
|
55
|
+
## Trace runs from any app
|
|
56
|
+
```python
|
|
57
|
+
from windhover import WindhoverTracer
|
|
58
|
+
graph.invoke(input, config={"callbacks": [WindhoverTracer("http://HOST:8090")]})
|
|
59
|
+
```
|
|
60
|
+
Node spans, LLM calls (model/prompt/response/tokens/cost), and tools show up in **Runs** —
|
|
61
|
+
wherever your app runs. Non-blocking, best-effort; never raises into your graph.
|
|
62
|
+
|
|
63
|
+
Sessions and tags use standard LangChain config — no Windhover imports needed beyond the tracer:
|
|
64
|
+
```python
|
|
65
|
+
graph.invoke(input, config={
|
|
66
|
+
"callbacks": [WindhoverTracer("http://HOST:8090")],
|
|
67
|
+
"metadata": {"windhover_session": "chat-42", "windhover_tags": ["prod"]},
|
|
68
|
+
"tags": ["also-captured"], # langgraph-internal tags are filtered out
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Features
|
|
73
|
+
- **Any graph** — topology from `graph.get_graph()`; input form from its state schema.
|
|
74
|
+
- **Full trace tree** — nodes → nested LLM / tool / **retriever** spans: prompts, responses,
|
|
75
|
+
tokens, cost, latency, retrieved documents with their metadata.
|
|
76
|
+
- **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
|
|
77
|
+
- **Error forensics** — failed runs show the full traceback; the failing node turns red on the
|
|
78
|
+
graph, and the node's source renders with the **throwing line highlighted**.
|
|
79
|
+
- **Human-in-the-loop aware** — a graph paused on `interrupt()` shows an amber **interrupted**
|
|
80
|
+
status plus the payload it's asking a human about.
|
|
81
|
+
- **State evolution** — every trace shows which state keys each node wrote, in order.
|
|
82
|
+
- **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
|
|
83
|
+
(`get_graph(xray=True)`).
|
|
84
|
+
- **Search & filters** — full-text over prompts/payloads/errors (FTS5, LIKE fallback),
|
|
85
|
+
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
86
|
+
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
87
|
+
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
88
|
+
- **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
|
|
89
|
+
- **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
|
|
90
|
+
- **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
|
|
91
|
+
- **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
|
|
92
|
+
|
|
93
|
+
## Scores API
|
|
94
|
+
```bash
|
|
95
|
+
curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
|
|
96
|
+
-d '{"name": "accuracy", "value": 0.92, "comment": "vs golden set"}'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Config (env)
|
|
100
|
+
`WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
|
|
101
|
+
· `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
|
|
102
|
+
· `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h).
|
|
103
|
+
Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
|
|
104
|
+
|
|
105
|
+
## Notes
|
|
106
|
+
Runs use the imported graph (restart to run new code); the *view* always reflects
|
|
107
|
+
current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
|
|
108
|
+
Deep links: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
MIT.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
windhover/__init__.py,sha256=U2jUQF-jNb8VlWsEufEv6msDC1JgIpB0DM8ei5q1cqA,189
|
|
2
|
+
windhover/config.py,sha256=q77ZzFfzVwHA0vzMcM_pMQtGC3Ry4JYzRvTeb8RLYjA,1288
|
|
3
|
+
windhover/demo_graph.py,sha256=QGzuC4-FnvH7ySC0UplyKQVnieYTg-CJmn1GU44_rFU,1784
|
|
4
|
+
windhover/extract.py,sha256=PNSmccsvKWgS1DaGwVAk6G1_kgNMBRNNc4GGv3dBFKw,3572
|
|
5
|
+
windhover/pricing.json,sha256=PFVIaMKOV_m6WwUFIbATiG6ezqK4aFHetWLHqbjYqMU,737
|
|
6
|
+
windhover/server.py,sha256=Ycjm88jbqIBCuZTBJvvnKi4zT9PhBYYF3vqAu8EFRzU,11520
|
|
7
|
+
windhover/store.py,sha256=ftBBIehrcPFmGwBmnOjjBD1Z4ylS9M50TaYwK0laebc,18485
|
|
8
|
+
windhover/tracer.py,sha256=HIN44_lYdjqYSolZmQ7Ga4ViuwQ4xOWzVez1G18HXos,14359
|
|
9
|
+
windhover/static/icon.svg,sha256=bk8o_MuQUDktZr7gkDJcjBXYua4MGWsqLpk4X80h09M,5223
|
|
10
|
+
windhover/static/index.html,sha256=8646IwKv-39OQZRlyL-EkFRjDy2SxJwXA8amTr4geeg,70319
|
|
11
|
+
windhover/static/manifest.json,sha256=7iI_nFz2vDlUXEu0-yM48VxTgheIZ-IyILsBQeRzqx4,478
|
|
12
|
+
windhover/static/vendor/cytoscape-dagre.js,sha256=v3D-QCmR3L_zPgWn5KUnHHgCC7dehdHICrdTjkFXES4,12665
|
|
13
|
+
windhover/static/vendor/cytoscape.min.js,sha256=g-jFSmvsZVv9gd8H32BWScJor2muymel6i2lTqQtrIE,373304
|
|
14
|
+
windhover/static/vendor/dagre.min.js,sha256=YuuXh8z9vfQUjU2Z0x2_nuR3Dq_ugeY311m1KqwizVE,283803
|
|
15
|
+
windhover/static/vendor/fonts/fraunces-500.woff2,sha256=eXXB73yvidBgbdmF01q-MaYZwZJizxP1oFk4zGj7J7I,18000
|
|
16
|
+
windhover/static/vendor/fonts/fraunces-600-italic.woff2,sha256=I3EtthHQeZ34YFBPDmYmreIFiPqyuPSILQ5xGjMNobc,23032
|
|
17
|
+
windhover/static/vendor/fonts/fraunces-600.woff2,sha256=Oh3ncR0Ue61EIoJQRfh1l_13zKcufJbTsKgXNdAN2oI,18096
|
|
18
|
+
windhover-0.7.0.dist-info/METADATA,sha256=1Lazx7ccwNzmghG7y03A1UkbMqqhZNTdX6jLHIPPgEs,5716
|
|
19
|
+
windhover-0.7.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
windhover-0.7.0.dist-info/entry_points.txt,sha256=fS7ufKlG8-6PsU_-S2NTLVT-gP5RC3j7YlAeJt_PIZM,52
|
|
21
|
+
windhover-0.7.0.dist-info/licenses/LICENSE,sha256=VTp3f8eoENYAaleKt6xdLDOtIvUFCZJxTXt6G4FqsT0,1076
|
|
22
|
+
windhover-0.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 justfeltlikerunning
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|