windhover 0.12.0__tar.gz → 0.13.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.
- {windhover-0.12.0 → windhover-0.13.0}/PKG-INFO +4 -4
- {windhover-0.12.0 → windhover-0.13.0}/README.md +3 -3
- {windhover-0.12.0 → windhover-0.13.0}/docs/GUIDE.md +36 -1
- {windhover-0.12.0 → windhover-0.13.0}/pyproject.toml +1 -1
- {windhover-0.12.0 → windhover-0.13.0}/tests/test_smoke.py +21 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/config.py +2 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/server.py +30 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/index.html +1 -1
- {windhover-0.12.0 → windhover-0.13.0}/windhover/store.py +7 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/tracer.py +37 -8
- {windhover-0.12.0 → windhover-0.13.0}/.github/workflows/ci.yml +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/.gitignore +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/GUIDE.md +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/LICENSE +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/SPEC.md +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/docs/graph.png +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/docs/logo.svg +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/docs/runs.png +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/docs/social-preview.png +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/docs/stats.png +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/docs/trace.png +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/tests/__init__.py +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/__init__.py +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/demo_graph.py +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/extract.py +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/pricing.json +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/icon.svg +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/manifest.json +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: windhover
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
|
|
5
5
|
Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
|
|
6
6
|
Author: justfeltlikerunning
|
|
@@ -17,7 +17,7 @@ Requires-Dist: langgraph>=0.2; extra == 'dev'
|
|
|
17
17
|
Requires-Dist: pytest; extra == 'dev'
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
20
|
-
<p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
|
|
20
|
+
<p align="center"><img src="https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
|
|
21
21
|
<h1 align="center">Windhover</h1>
|
|
22
22
|
<p align="center">
|
|
23
23
|
<a href="https://pypi.org/project/windhover/"><img src="https://img.shields.io/pypi/v/windhover" alt="PyPI"></a>
|
|
@@ -40,11 +40,11 @@ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP
|
|
|
40
40
|
|
|
41
41
|
| Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
|
|
42
42
|
|---|---|
|
|
43
|
-
|  |  |
|
|
43
|
+
|  |  |
|
|
44
44
|
|
|
45
45
|
| Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
|
|
46
46
|
|---|---|
|
|
47
|
-
|  |  |
|
|
47
|
+
|  |  |
|
|
48
48
|
|
|
49
49
|
## Quick start
|
|
50
50
|
```bash
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
|
|
1
|
+
<p align="center"><img src="https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
|
|
2
2
|
<h1 align="center">Windhover</h1>
|
|
3
3
|
<p align="center">
|
|
4
4
|
<a href="https://pypi.org/project/windhover/"><img src="https://img.shields.io/pypi/v/windhover" alt="PyPI"></a>
|
|
@@ -21,11 +21,11 @@ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP
|
|
|
21
21
|
|
|
22
22
|
| Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
|
|
23
23
|
|---|---|
|
|
24
|
-
|  |  |
|
|
24
|
+
|  |  |
|
|
25
25
|
|
|
26
26
|
| Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
|
|
27
27
|
|---|---|
|
|
28
|
-
|  |  |
|
|
28
|
+
|  |  |
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
31
31
|
```bash
|
|
@@ -187,11 +187,46 @@ for semantic grading, run your own judge and `POST /api/runs/{id}/scores`.
|
|
|
187
187
|
shows the tool-call JSON as the LLM output instead of an empty box.
|
|
188
188
|
- **Auth**: set `WINDHOVER_TOKEN` to require `Authorization: Bearer …` (or `?token=`)
|
|
189
189
|
on `/api`. The UI prompts once and remembers. The HTML shell itself stays public —
|
|
190
|
-
it contains no data.
|
|
190
|
+
it contains no data. External tracers pass it as `WindhoverTracer(url, token="…")`.
|
|
191
|
+
**Set the token before exposing Windhover beyond localhost** — the HITL endpoints can
|
|
192
|
+
resume and edit graph state.
|
|
193
|
+
- **Alerts**: `WINDHOVER_WEBHOOK=https://…` POSTs a compact JSON summary whenever a run
|
|
194
|
+
errors or pauses on an interrupt (`{source, graph, run_id, status, error, text, …}`) —
|
|
195
|
+
point it at a Slack/Discord webhook or your own receiver. Fire-and-forget.
|
|
191
196
|
- **Retention**: `WINDHOVER_RETENTION_DAYS=30` prunes old runs on startup and every 6h.
|
|
192
197
|
Default keeps everything.
|
|
193
198
|
- **Deep links**: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
|
|
194
199
|
|
|
200
|
+
## Running it for real
|
|
201
|
+
|
|
202
|
+
- **The tracer never slows your app.** Events go onto a bounded in-memory queue drained
|
|
203
|
+
by a background thread; if the Windhover host is slow or down, events are dropped —
|
|
204
|
+
your pipeline's latency is unchanged. (Sheds oldest-first at 2,000 queued events.)
|
|
205
|
+
- **Systemd** (adjust paths):
|
|
206
|
+
```ini
|
|
207
|
+
[Unit]
|
|
208
|
+
Description=Windhover
|
|
209
|
+
After=network.target
|
|
210
|
+
|
|
211
|
+
[Service]
|
|
212
|
+
WorkingDirectory=/opt/myapp
|
|
213
|
+
Environment=WINDHOVER_GRAPH=myapp.graphs:graph
|
|
214
|
+
Environment=WINDHOVER_DB=/var/lib/windhover/windhover.db
|
|
215
|
+
Environment=WINDHOVER_TOKEN=change-me
|
|
216
|
+
Environment=WINDHOVER_RETENTION_DAYS=30
|
|
217
|
+
ExecStart=/opt/myapp/.venv/bin/windhover
|
|
218
|
+
Restart=always
|
|
219
|
+
TimeoutStopSec=10
|
|
220
|
+
|
|
221
|
+
[Install]
|
|
222
|
+
WantedBy=multi-user.target
|
|
223
|
+
```
|
|
224
|
+
`TimeoutStopSec` matters: open SSE connections otherwise stretch restarts.
|
|
225
|
+
- **Reverse proxy**: plain HTTP + SSE — any proxy works; disable response buffering for
|
|
226
|
+
`/api/events` and `/api/run` (nginx: `proxy_buffering off;`).
|
|
227
|
+
- **Backups**: runs live in one SQLite file (`WINDHOVER_DB`); copy it (plus `-wal`) or
|
|
228
|
+
rely on `/api/export` for tabular run data.
|
|
229
|
+
|
|
195
230
|
## Try it all on the demo graph
|
|
196
231
|
|
|
197
232
|
`WINDHOVER_GRAPH=windhover.demo_graph:graph windhover`, then:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "windhover"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.13.0"
|
|
8
8
|
description = "Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -573,6 +573,26 @@ def test_langgraph_json_discovery():
|
|
|
573
573
|
print("langgraph.json discovery OK")
|
|
574
574
|
|
|
575
575
|
|
|
576
|
+
def test_nonblocking_sink_and_webhook_hook():
|
|
577
|
+
# sink to an unreachable host must return instantly and never raise
|
|
578
|
+
from windhover.tracer import http_sink
|
|
579
|
+
sink = http_sink("http://10.255.255.1:9", max_queue=10)
|
|
580
|
+
t0 = time.time()
|
|
581
|
+
for i in range(50):
|
|
582
|
+
sink({"kind": "span", "i": i})
|
|
583
|
+
assert time.time() - t0 < 0.5, "sink blocked the caller"
|
|
584
|
+
|
|
585
|
+
# store hook fires with the closing status (webhook wiring point)
|
|
586
|
+
p = tempfile.mktemp(suffix=".db")
|
|
587
|
+
s = Store(p)
|
|
588
|
+
fired = []
|
|
589
|
+
s.on_run_closed = lambda summary: fired.append(summary)
|
|
590
|
+
s.open_run({"id": "w1", "graph": "g", "started_ms": 1000})
|
|
591
|
+
s.close_run("w1", "error", 1500, error="Boom: it broke")
|
|
592
|
+
assert fired and fired[0]["status"] == "error" and "Boom" in fired[0]["error"]
|
|
593
|
+
print("non-blocking sink + webhook hook OK")
|
|
594
|
+
|
|
595
|
+
|
|
576
596
|
if __name__ == "__main__":
|
|
577
597
|
test_cost(); print("cost OK")
|
|
578
598
|
test_store_roundtrip()
|
|
@@ -598,4 +618,5 @@ if __name__ == "__main__":
|
|
|
598
618
|
test_functional_api_tracing()
|
|
599
619
|
test_message_serialization()
|
|
600
620
|
test_langgraph_json_discovery()
|
|
621
|
+
test_nonblocking_sink_and_webhook_hook()
|
|
601
622
|
print("ALL SMOKE TESTS PASSED")
|
|
@@ -18,6 +18,7 @@ class Config:
|
|
|
18
18
|
pricing_path: str
|
|
19
19
|
retention_days: int # 0 = keep runs forever
|
|
20
20
|
token: str # WINDHOVER_TOKEN: require Bearer/query token on /api ("" = open)
|
|
21
|
+
webhook: str # WINDHOVER_WEBHOOK: POST run summaries on error/interrupted ("" = off)
|
|
21
22
|
|
|
22
23
|
@staticmethod
|
|
23
24
|
def _discover() -> tuple[str, str]:
|
|
@@ -58,4 +59,5 @@ class Config:
|
|
|
58
59
|
pricing_path=os.environ.get("WINDHOVER_PRICING", str(PKG_DIR / "pricing.json")),
|
|
59
60
|
retention_days=int(os.environ.get("WINDHOVER_RETENTION_DAYS", "0") or 0),
|
|
60
61
|
token=os.environ.get("WINDHOVER_TOKEN", ""),
|
|
62
|
+
webhook=os.environ.get("WINDHOVER_WEBHOOK", ""),
|
|
61
63
|
)
|
|
@@ -140,6 +140,36 @@ if cfg.retention_days > 0:
|
|
|
140
140
|
threading.Thread(target=_retention_loop, daemon=True).start()
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
def _fire_webhook(summary: dict) -> None:
|
|
144
|
+
"""POST a compact alert when a run needs attention. Fire-and-forget."""
|
|
145
|
+
if summary.get("status") not in ("error", "interrupted"):
|
|
146
|
+
return
|
|
147
|
+
def _post():
|
|
148
|
+
try:
|
|
149
|
+
import urllib.request
|
|
150
|
+
run = store.run_detail(summary["id"]) or summary
|
|
151
|
+
body = {
|
|
152
|
+
"source": "windhover", "graph": run.get("graph") or cfg.graph_ref,
|
|
153
|
+
"run_id": summary["id"], "status": summary["status"],
|
|
154
|
+
"duration_ms": summary.get("duration_ms"),
|
|
155
|
+
"session": run.get("session"), "thread_id": run.get("thread_id"),
|
|
156
|
+
"error": (str(summary.get("error") or "").strip().splitlines() or [None])[-1],
|
|
157
|
+
"text": f"[windhover] run {summary['id']} {summary['status']}"
|
|
158
|
+
f" ({run.get('graph') or cfg.graph_ref})",
|
|
159
|
+
}
|
|
160
|
+
req = urllib.request.Request(
|
|
161
|
+
cfg.webhook, data=json.dumps(body).encode(),
|
|
162
|
+
headers={"Content-Type": "application/json"})
|
|
163
|
+
urllib.request.urlopen(req, timeout=5)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
threading.Thread(target=_post, daemon=True).start()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if cfg.webhook:
|
|
170
|
+
store.on_run_closed = _fire_webhook
|
|
171
|
+
|
|
172
|
+
|
|
143
173
|
def _template(schema: dict) -> dict:
|
|
144
174
|
out = {}
|
|
145
175
|
for k, p in (schema.get("properties") or {}).items():
|
|
@@ -459,7 +459,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
459
459
|
<button data-v="memory" id="nav-mem" style="display:none">${ICON.mem} Memory</button>
|
|
460
460
|
<button data-v="stats">${ICON.chart} Stats</button>
|
|
461
461
|
</nav>
|
|
462
|
-
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.
|
|
462
|
+
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.13</span></div>
|
|
463
463
|
</aside>
|
|
464
464
|
|
|
465
465
|
<main>
|
|
@@ -27,6 +27,7 @@ class Store:
|
|
|
27
27
|
self.path = path
|
|
28
28
|
self.has_fts = False
|
|
29
29
|
self.has_json1 = False
|
|
30
|
+
self.on_run_closed = None # optional hook(run_dict) — used for webhooks
|
|
30
31
|
self._init()
|
|
31
32
|
|
|
32
33
|
def _conn(self) -> sqlite3.Connection:
|
|
@@ -171,6 +172,12 @@ class Store:
|
|
|
171
172
|
WHERE id=?""",
|
|
172
173
|
(status, ended_ms, ended_ms - started, error, agg["nc"], agg["lc"],
|
|
173
174
|
pt, ct, (pt or 0) + (ct or 0) if (pt or ct) else None, agg["cost"], run_id))
|
|
175
|
+
if self.on_run_closed is not None:
|
|
176
|
+
try:
|
|
177
|
+
self.on_run_closed({"id": run_id, "status": status, "error": error,
|
|
178
|
+
"duration_ms": ended_ms - started})
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
174
181
|
|
|
175
182
|
def update_run_meta(self, run_id: str, tags: Optional[list] = None,
|
|
176
183
|
bookmarked: Optional[bool] = None) -> bool:
|
|
@@ -456,19 +456,48 @@ def db_sink(store) -> Callable[[dict], None]:
|
|
|
456
456
|
return lambda ev: apply_to_store(store, ev, source="ui")
|
|
457
457
|
|
|
458
458
|
|
|
459
|
-
def http_sink(base_url: str
|
|
460
|
-
|
|
459
|
+
def http_sink(base_url: str, token: Optional[str] = None,
|
|
460
|
+
max_queue: int = 2000) -> Callable[[dict], None]:
|
|
461
|
+
"""Non-blocking: events go onto a bounded queue drained by a daemon thread,
|
|
462
|
+
so a slow or unreachable Windhover host NEVER adds latency to the traced
|
|
463
|
+
app. On overflow the oldest events are dropped (observability is
|
|
464
|
+
best-effort; the app comes first)."""
|
|
465
|
+
import queue as _q, threading as _t, urllib.request
|
|
461
466
|
url = base_url.rstrip("/") + "/api/ingest"
|
|
467
|
+
headers = {"Content-Type": "application/json"}
|
|
468
|
+
if token:
|
|
469
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
470
|
+
buf: "_q.Queue" = _q.Queue(maxsize=max_queue)
|
|
471
|
+
|
|
472
|
+
def _drain():
|
|
473
|
+
while True:
|
|
474
|
+
ev = buf.get()
|
|
475
|
+
try:
|
|
476
|
+
req = urllib.request.Request(url, data=json.dumps(ev).encode(),
|
|
477
|
+
headers=headers)
|
|
478
|
+
urllib.request.urlopen(req, timeout=5)
|
|
479
|
+
except Exception:
|
|
480
|
+
pass # drop — never retry-storm a down collector
|
|
481
|
+
|
|
482
|
+
_t.Thread(target=_drain, daemon=True).start()
|
|
462
483
|
|
|
463
484
|
def sink(ev: dict) -> None:
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
485
|
+
try:
|
|
486
|
+
buf.put_nowait(ev)
|
|
487
|
+
except _q.Full:
|
|
488
|
+
try:
|
|
489
|
+
buf.get_nowait() # shed oldest, keep newest
|
|
490
|
+
buf.put_nowait(ev)
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
467
493
|
return sink
|
|
468
494
|
|
|
469
495
|
|
|
470
496
|
class WindhoverTracer(SpanBuilder):
|
|
471
|
-
"""Drop into any app: config={"callbacks": [WindhoverTracer("http://host:8090")]}.
|
|
497
|
+
"""Drop into any app: config={"callbacks": [WindhoverTracer("http://host:8090")]}.
|
|
498
|
+
Non-blocking; pass token= when the collector sets WINDHOVER_TOKEN."""
|
|
472
499
|
def __init__(self, base_url: str, name: str = "external",
|
|
473
|
-
session: Optional[str] = None, tags: Optional[list] = None
|
|
474
|
-
|
|
500
|
+
session: Optional[str] = None, tags: Optional[list] = None,
|
|
501
|
+
token: Optional[str] = None):
|
|
502
|
+
super().__init__(http_sink(base_url, token=token),
|
|
503
|
+
run_name=name, session=session, tags=tags)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|