windhover 0.14.0__tar.gz → 0.14.1__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.14.0 → windhover-0.14.1}/PKG-INFO +1 -1
- {windhover-0.14.0 → windhover-0.14.1}/docs/GUIDE.md +4 -3
- {windhover-0.14.0 → windhover-0.14.1}/pyproject.toml +1 -1
- {windhover-0.14.0 → windhover-0.14.1}/tests/test_smoke.py +26 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/server.py +4 -4
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/index.html +14 -4
- {windhover-0.14.0 → windhover-0.14.1}/windhover/store.py +32 -19
- {windhover-0.14.0 → windhover-0.14.1}/.github/workflows/ci.yml +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/.gitignore +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/GUIDE.md +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/LICENSE +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/README.md +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/SPEC.md +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/docs/graph.png +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/docs/logo.svg +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/docs/runs.png +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/docs/social-preview.png +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/docs/stats.png +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/docs/trace.png +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/tests/__init__.py +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/__init__.py +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/config.py +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/demo_graph.py +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/demo_rag.py +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/extract.py +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/pricing.json +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/icon.svg +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/manifest.json +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
- {windhover-0.14.0 → windhover-0.14.1}/windhover/tracer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: windhover
|
|
3
|
-
Version: 0.14.
|
|
3
|
+
Version: 0.14.1
|
|
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
|
|
@@ -60,9 +60,10 @@ Projects with a **`langgraph.json`** (the LangGraph Studio/CLI convention) need
|
|
|
60
60
|
run `windhover` in the project directory and every graph the file defines is served.
|
|
61
61
|
|
|
62
62
|
**Multiple graphs**: `WINDHOVER_GRAPH="checkout=app.flows:checkout,support=app.flows:support"`
|
|
63
|
-
puts a graph selector in the top bar. The selector scopes
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
puts a graph selector in the top bar. The selector scopes **everything** — canvas, New run,
|
|
64
|
+
node source, Memory, Runs, Sessions, Stats (so per-node latency and model usage never mix
|
|
65
|
+
graphs). The Runs page keeps an "All graphs" override in its own filter; human-in-the-loop
|
|
66
|
+
actions always follow the graph the run belongs to.
|
|
66
67
|
|
|
67
68
|
- The canvas always reflects **current-on-disk** topology (a subprocess re-extracts on
|
|
68
69
|
file change). Runs, however, execute the graph **imported at startup** — restart the
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "windhover"
|
|
7
|
-
version = "0.14.
|
|
7
|
+
version = "0.14.1"
|
|
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"
|
|
@@ -587,6 +587,31 @@ def test_env_multi_graph_parsing():
|
|
|
587
587
|
print("env multi-graph parsing OK")
|
|
588
588
|
|
|
589
589
|
|
|
590
|
+
def test_graph_scoped_stats_and_sessions():
|
|
591
|
+
p = tempfile.mktemp(suffix=".db")
|
|
592
|
+
s = Store(p)
|
|
593
|
+
now = int(time.time() * 1000)
|
|
594
|
+
for i, g in enumerate(("alpha", "alpha", "beta")):
|
|
595
|
+
rid = f"g{i}"
|
|
596
|
+
s.open_run({"id": rid, "graph": g, "session": f"sess-{g}", "started_ms": now - i})
|
|
597
|
+
s.add_span({"id": f"s{i}", "run_id": rid, "type": "node", "name": "shared_name",
|
|
598
|
+
"seq": 0, "dur_ms": 100 * (i + 1)})
|
|
599
|
+
s.add_span({"id": f"l{i}", "run_id": rid, "type": "llm", "name": "m", "seq": 1,
|
|
600
|
+
"model": f"model-{g}", "dur_ms": 5, "prompt_tokens": 10,
|
|
601
|
+
"completion_tokens": 1})
|
|
602
|
+
s.close_run(rid, "done", now + 10)
|
|
603
|
+
st_a = s.stats(graph="alpha")
|
|
604
|
+
assert st_a["totals"]["runs"] == 2
|
|
605
|
+
assert st_a["per_node"][0]["n"] == 2 # only alpha's spans, not beta's
|
|
606
|
+
assert [m["model"] for m in st_a["models"]] == ["model-alpha"]
|
|
607
|
+
st_all = s.stats()
|
|
608
|
+
assert st_all["totals"]["runs"] == 3 and st_all["per_node"][0]["n"] == 3
|
|
609
|
+
ses_b = s.sessions(graph="beta")
|
|
610
|
+
assert [x["session"] for x in ses_b] == ["sess-beta"]
|
|
611
|
+
assert len(s.sessions()) == 2
|
|
612
|
+
print("graph-scoped stats/sessions OK")
|
|
613
|
+
|
|
614
|
+
|
|
590
615
|
def test_nonblocking_sink_and_webhook_hook():
|
|
591
616
|
# sink to an unreachable host must return instantly and never raise
|
|
592
617
|
from windhover.tracer import http_sink
|
|
@@ -633,5 +658,6 @@ if __name__ == "__main__":
|
|
|
633
658
|
test_message_serialization()
|
|
634
659
|
test_langgraph_json_discovery()
|
|
635
660
|
test_env_multi_graph_parsing()
|
|
661
|
+
test_graph_scoped_stats_and_sessions()
|
|
636
662
|
test_nonblocking_sink_and_webhook_hook()
|
|
637
663
|
print("ALL SMOKE TESTS PASSED")
|
|
@@ -414,8 +414,8 @@ def api_runs(limit: int = 50, offset: int = 0, q: str = None, status: str = None
|
|
|
414
414
|
|
|
415
415
|
|
|
416
416
|
@app.get("/api/sessions")
|
|
417
|
-
def api_sessions(limit: int = 100):
|
|
418
|
-
return JSONResponse(store.sessions(limit=limit))
|
|
417
|
+
def api_sessions(limit: int = 100, graph: str = None):
|
|
418
|
+
return JSONResponse(store.sessions(limit=limit, graph=graph or None))
|
|
419
419
|
|
|
420
420
|
|
|
421
421
|
@app.get("/api/runs/{run_id}")
|
|
@@ -622,8 +622,8 @@ def api_dataset_run(ds_id: str, graph: str = None):
|
|
|
622
622
|
|
|
623
623
|
|
|
624
624
|
@app.get("/api/stats")
|
|
625
|
-
def api_stats(days: int = 30):
|
|
626
|
-
return JSONResponse(store.stats(days=max(1, min(days, 365))))
|
|
625
|
+
def api_stats(days: int = 30, graph: str = None):
|
|
626
|
+
return JSONResponse(store.stats(days=max(1, min(days, 365)), graph=graph or None))
|
|
627
627
|
|
|
628
628
|
|
|
629
629
|
@app.get("/api/events")
|
|
@@ -526,7 +526,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
526
526
|
</div>
|
|
527
527
|
<div id="view-stats" style="display:none">
|
|
528
528
|
<div class="page">
|
|
529
|
-
<div class="page-head"><h1>Stats</h1><span class="sub">all recorded runs</span></div>
|
|
529
|
+
<div class="page-head"><h1>Stats</h1><span class="sub" id="stats-sub">all recorded runs</span></div>
|
|
530
530
|
<div id="stats-body"></div>
|
|
531
531
|
</div>
|
|
532
532
|
</div>
|
|
@@ -642,14 +642,22 @@ async function loadGraphList(){
|
|
|
642
642
|
sel.style.display=''; $('#gname').style.display='none';
|
|
643
643
|
sel.innerHTML=window.__graphList.map(g=>`<option ${g===GSEL?'selected':''}>${esc(g)}</option>`).join('');
|
|
644
644
|
sel.onchange=()=>{ GSEL=sel.value; localStorage.setItem('wh-graph',GSEL);
|
|
645
|
-
|
|
645
|
+
RF.graph=GSEL; const rg=$('#rgraph'); if(rg) rg.value=GSEL;
|
|
646
|
+
loadGraph(true); probeMemory();
|
|
647
|
+
if(VIEW==='memory') loadMemory();
|
|
648
|
+
if(VIEW==='runs') loadRuns();
|
|
649
|
+
if(VIEW==='sessions') loadSessions();
|
|
650
|
+
if(VIEW==='stats'){ loadStats(); loadDatasets(); } };
|
|
646
651
|
const rg=$('#rgraph');
|
|
647
652
|
if(rg){ rg.style.display='';
|
|
648
653
|
rg.innerHTML='<option value="">All graphs</option>'+window.__graphList.map(g=>`<option>${esc(g)}</option>`).join('');
|
|
654
|
+
RF.graph=GSEL; rg.value=GSEL;
|
|
649
655
|
rg.onchange=e=>{RF.graph=e.target.value;RF.offset=0;loadRuns();}; }
|
|
650
656
|
}
|
|
651
657
|
}catch(e){}
|
|
652
658
|
}
|
|
659
|
+
function multiGraph(){ return (window.__graphList||[]).length>1; }
|
|
660
|
+
function gscope(){ return multiGraph()&&GSEL?'graph='+encodeURIComponent(GSEL):''; }
|
|
653
661
|
function gparam(pfx){ return GSEL?pfx+'graph='+encodeURIComponent(GSEL):''; }
|
|
654
662
|
function rgparam(){ const g=(window.__run||{}).graph||GSEL; return g?'graph='+encodeURIComponent(g):''; }
|
|
655
663
|
let XRAY=false;
|
|
@@ -889,7 +897,7 @@ $('#rbook').onclick=()=>{RF.bookmarked=!RF.bookmarked;$('#rbook').classList.togg
|
|
|
889
897
|
|
|
890
898
|
/* ---------- sessions ---------- */
|
|
891
899
|
async function loadSessions(){
|
|
892
|
-
const ss=await(await fetch('/api/sessions')).json();
|
|
900
|
+
const ss=await(await fetch('/api/sessions?'+gscope())).json();
|
|
893
901
|
$('#sess-sub').textContent=ss.length?`${ss.length} session${ss.length===1?'':'s'}`:'';
|
|
894
902
|
if(!ss.length){ $('#sess-wrap').innerHTML='<div class="empty"><div class="t">No sessions yet</div>Pass <span class="mono">session=</span> to <span class="mono">WindhoverTracer</span>, or <span class="mono">config={"metadata": {"windhover_session": …}}</span> in any LangChain/LangGraph app.</div>'; return; }
|
|
895
903
|
$('#sess-wrap').innerHTML=`<div style="overflow:auto"><table>
|
|
@@ -1178,11 +1186,13 @@ function chartCard(title,daily,val,errKey){
|
|
|
1178
1186
|
<div class="clabels"><span>${daily[0]?.day||''}</span><span>${daily[daily.length-1]?.day||''}</span></div></div></div>`;
|
|
1179
1187
|
}
|
|
1180
1188
|
async function loadStats(){
|
|
1181
|
-
const s=await(await fetch('/api/stats?days=30')).json(); const t=s.totals||{};
|
|
1189
|
+
const s=await(await fetch('/api/stats?days=30&'+gscope())).json(); const t=s.totals||{};
|
|
1182
1190
|
const errRate=t.runs?Math.round((t.errors||0)/t.runs*100):0;
|
|
1183
1191
|
const max=Math.max(1,...(s.per_node||[]).map(n=>n.avg_ms||0));
|
|
1184
1192
|
const daily=s.daily||[], models=s.models||[];
|
|
1185
1193
|
const db=t.db_bytes!=null?(t.db_bytes/1048576).toFixed(1)+' MB db':'';
|
|
1194
|
+
const ssub=document.getElementById('stats-sub');
|
|
1195
|
+
if(ssub) ssub.textContent=(multiGraph()&&GSEL)?('graph: '+GSEL):'all recorded runs';
|
|
1186
1196
|
$('#stats-body').innerHTML=`
|
|
1187
1197
|
<div class="metrics">
|
|
1188
1198
|
<div class="metric"><div class="v num">${t.runs||0}</div><div class="l">Total runs</div></div>
|
|
@@ -310,14 +310,18 @@ class Store:
|
|
|
310
310
|
r["scores"] = sc.get(r["id"]) or None
|
|
311
311
|
return {"runs": rows, "total": total, "limit": limit, "offset": offset}
|
|
312
312
|
|
|
313
|
-
def sessions(self, limit: int = 100) -> list[dict]:
|
|
313
|
+
def sessions(self, limit: int = 100, graph: Optional[str] = None) -> list[dict]:
|
|
314
|
+
cond, args = "", []
|
|
315
|
+
if graph:
|
|
316
|
+
cond = " AND graph=?"; args.append(graph)
|
|
314
317
|
with _lock, self._conn() as c:
|
|
315
|
-
return [dict(r) for r in c.execute("""SELECT session,
|
|
318
|
+
return [dict(r) for r in c.execute(f"""SELECT session,
|
|
316
319
|
COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
|
|
317
320
|
MIN(started_ms) first_ms, MAX(started_ms) last_ms,
|
|
318
321
|
SUM(total_tokens) tokens, SUM(cost_usd) cost, SUM(duration_ms) duration_ms
|
|
319
|
-
FROM runs WHERE session IS NOT NULL AND session!=''
|
|
320
|
-
GROUP BY session ORDER BY last_ms DESC LIMIT ?""",
|
|
322
|
+
FROM runs WHERE session IS NOT NULL AND session!=''{cond}
|
|
323
|
+
GROUP BY session ORDER BY last_ms DESC LIMIT ?""",
|
|
324
|
+
(*args, limit)).fetchall()]
|
|
321
325
|
|
|
322
326
|
def run_detail(self, run_id: str) -> Optional[dict]:
|
|
323
327
|
with _lock, self._conn() as c:
|
|
@@ -366,27 +370,36 @@ class Store:
|
|
|
366
370
|
s[f] = json.loads(s[f]) if s.get(f) else None
|
|
367
371
|
return {"name": name, "summary": dict(agg), "recent": rows}
|
|
368
372
|
|
|
369
|
-
def stats(self, days: int = 30) -> dict:
|
|
373
|
+
def stats(self, days: int = 30, graph: Optional[str] = None) -> dict:
|
|
370
374
|
cutoff = int((time.time() - days * 86400) * 1000)
|
|
375
|
+
rcond, rargs = ("", [])
|
|
376
|
+
scond, sargs = ("", [])
|
|
377
|
+
if graph:
|
|
378
|
+
rcond = " WHERE graph=?"; rargs = [graph]
|
|
379
|
+
scond = " AND runs.graph=?"; sargs = [graph]
|
|
371
380
|
with _lock, self._conn() as c:
|
|
372
|
-
tot = c.execute("""SELECT COUNT(*) runs,
|
|
381
|
+
tot = c.execute(f"""SELECT COUNT(*) runs,
|
|
373
382
|
COUNT(*) FILTER (WHERE status='error') errors,
|
|
374
383
|
SUM(total_tokens) tokens, SUM(cost_usd) cost,
|
|
375
|
-
SUM(llm_calls) llm_calls FROM runs""").fetchone()
|
|
376
|
-
per_node = c.execute("""SELECT name, COUNT(*) n,
|
|
377
|
-
SUM(cost_usd) cost
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
384
|
+
SUM(llm_calls) llm_calls FROM runs{rcond}""", rargs).fetchone()
|
|
385
|
+
per_node = c.execute(f"""SELECT spans.name, COUNT(*) n,
|
|
386
|
+
AVG(spans.dur_ms) avg_ms, SUM(spans.cost_usd) cost
|
|
387
|
+
FROM spans JOIN runs ON runs.id = spans.run_id
|
|
388
|
+
WHERE spans.type='node'{scond}
|
|
389
|
+
GROUP BY spans.name ORDER BY avg_ms DESC LIMIT 20""", sargs).fetchall()
|
|
390
|
+
models = c.execute(f"""SELECT spans.model, COUNT(*) calls,
|
|
391
|
+
SUM(spans.prompt_tokens) prompt_tokens,
|
|
392
|
+
SUM(spans.completion_tokens) completion_tokens,
|
|
393
|
+
SUM(spans.cost_usd) cost, AVG(spans.dur_ms) avg_ms
|
|
394
|
+
FROM spans JOIN runs ON runs.id = spans.run_id
|
|
395
|
+
WHERE spans.type='llm' AND spans.model IS NOT NULL{scond}
|
|
396
|
+
GROUP BY spans.model ORDER BY calls DESC LIMIT 20""", sargs).fetchall()
|
|
397
|
+
daily = c.execute(f"""SELECT
|
|
385
398
|
strftime('%Y-%m-%d', started_ms/1000, 'unixepoch') day,
|
|
386
399
|
COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
|
|
387
400
|
SUM(total_tokens) tokens, SUM(cost_usd) cost
|
|
388
|
-
FROM runs WHERE started_ms >= ?
|
|
389
|
-
GROUP BY day ORDER BY day""", (cutoff,)).fetchall()
|
|
401
|
+
FROM runs WHERE started_ms >= ?{' AND graph=?' if graph else ''}
|
|
402
|
+
GROUP BY day ORDER BY day""", (cutoff, *rargs)).fetchall()
|
|
390
403
|
try:
|
|
391
404
|
db_bytes = os.path.getsize(self.path)
|
|
392
405
|
except OSError:
|
|
@@ -395,4 +408,4 @@ class Store:
|
|
|
395
408
|
"per_node": [dict(r) for r in per_node],
|
|
396
409
|
"models": [dict(r) for r in models],
|
|
397
410
|
"daily": [dict(r) for r in daily],
|
|
398
|
-
"days": days}
|
|
411
|
+
"days": days, "graph": graph}
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|