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.
Files changed (34) hide show
  1. {windhover-0.12.0 → windhover-0.13.0}/PKG-INFO +4 -4
  2. {windhover-0.12.0 → windhover-0.13.0}/README.md +3 -3
  3. {windhover-0.12.0 → windhover-0.13.0}/docs/GUIDE.md +36 -1
  4. {windhover-0.12.0 → windhover-0.13.0}/pyproject.toml +1 -1
  5. {windhover-0.12.0 → windhover-0.13.0}/tests/test_smoke.py +21 -0
  6. {windhover-0.12.0 → windhover-0.13.0}/windhover/config.py +2 -0
  7. {windhover-0.12.0 → windhover-0.13.0}/windhover/server.py +30 -0
  8. {windhover-0.12.0 → windhover-0.13.0}/windhover/static/index.html +1 -1
  9. {windhover-0.12.0 → windhover-0.13.0}/windhover/store.py +7 -0
  10. {windhover-0.12.0 → windhover-0.13.0}/windhover/tracer.py +37 -8
  11. {windhover-0.12.0 → windhover-0.13.0}/.github/workflows/ci.yml +0 -0
  12. {windhover-0.12.0 → windhover-0.13.0}/.gitignore +0 -0
  13. {windhover-0.12.0 → windhover-0.13.0}/GUIDE.md +0 -0
  14. {windhover-0.12.0 → windhover-0.13.0}/LICENSE +0 -0
  15. {windhover-0.12.0 → windhover-0.13.0}/SPEC.md +0 -0
  16. {windhover-0.12.0 → windhover-0.13.0}/docs/graph.png +0 -0
  17. {windhover-0.12.0 → windhover-0.13.0}/docs/logo.svg +0 -0
  18. {windhover-0.12.0 → windhover-0.13.0}/docs/runs.png +0 -0
  19. {windhover-0.12.0 → windhover-0.13.0}/docs/social-preview.png +0 -0
  20. {windhover-0.12.0 → windhover-0.13.0}/docs/stats.png +0 -0
  21. {windhover-0.12.0 → windhover-0.13.0}/docs/trace.png +0 -0
  22. {windhover-0.12.0 → windhover-0.13.0}/tests/__init__.py +0 -0
  23. {windhover-0.12.0 → windhover-0.13.0}/windhover/__init__.py +0 -0
  24. {windhover-0.12.0 → windhover-0.13.0}/windhover/demo_graph.py +0 -0
  25. {windhover-0.12.0 → windhover-0.13.0}/windhover/extract.py +0 -0
  26. {windhover-0.12.0 → windhover-0.13.0}/windhover/pricing.json +0 -0
  27. {windhover-0.12.0 → windhover-0.13.0}/windhover/static/icon.svg +0 -0
  28. {windhover-0.12.0 → windhover-0.13.0}/windhover/static/manifest.json +0 -0
  29. {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  30. {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  31. {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/dagre.min.js +0 -0
  32. {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  33. {windhover-0.12.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  34. {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.12.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
- | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
43
+ | ![Graph view](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/graph.png) | ![Trace drawer](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/trace.png) |
44
44
 
45
45
  | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
46
46
  |---|---|
47
- | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
47
+ | ![Runs table](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/runs.png) | ![Stats](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/stats.png) |
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
- | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
24
+ | ![Graph view](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/graph.png) | ![Trace drawer](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/trace.png) |
25
25
 
26
26
  | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
27
27
  |---|---|
28
- | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
28
+ | ![Runs table](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/runs.png) | ![Stats](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/stats.png) |
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.12.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.12</span></div>
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) -> Callable[[dict], None]:
460
- import urllib.request
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
- req = urllib.request.Request(url, data=json.dumps(ev).encode(),
465
- headers={"Content-Type": "application/json"})
466
- urllib.request.urlopen(req, timeout=3)
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
- super().__init__(http_sink(base_url), run_name=name, session=session, tags=tags)
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