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.
Files changed (35) hide show
  1. {windhover-0.14.0 → windhover-0.14.1}/PKG-INFO +1 -1
  2. {windhover-0.14.0 → windhover-0.14.1}/docs/GUIDE.md +4 -3
  3. {windhover-0.14.0 → windhover-0.14.1}/pyproject.toml +1 -1
  4. {windhover-0.14.0 → windhover-0.14.1}/tests/test_smoke.py +26 -0
  5. {windhover-0.14.0 → windhover-0.14.1}/windhover/server.py +4 -4
  6. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/index.html +14 -4
  7. {windhover-0.14.0 → windhover-0.14.1}/windhover/store.py +32 -19
  8. {windhover-0.14.0 → windhover-0.14.1}/.github/workflows/ci.yml +0 -0
  9. {windhover-0.14.0 → windhover-0.14.1}/.gitignore +0 -0
  10. {windhover-0.14.0 → windhover-0.14.1}/GUIDE.md +0 -0
  11. {windhover-0.14.0 → windhover-0.14.1}/LICENSE +0 -0
  12. {windhover-0.14.0 → windhover-0.14.1}/README.md +0 -0
  13. {windhover-0.14.0 → windhover-0.14.1}/SPEC.md +0 -0
  14. {windhover-0.14.0 → windhover-0.14.1}/docs/graph.png +0 -0
  15. {windhover-0.14.0 → windhover-0.14.1}/docs/logo.svg +0 -0
  16. {windhover-0.14.0 → windhover-0.14.1}/docs/runs.png +0 -0
  17. {windhover-0.14.0 → windhover-0.14.1}/docs/social-preview.png +0 -0
  18. {windhover-0.14.0 → windhover-0.14.1}/docs/stats.png +0 -0
  19. {windhover-0.14.0 → windhover-0.14.1}/docs/trace.png +0 -0
  20. {windhover-0.14.0 → windhover-0.14.1}/tests/__init__.py +0 -0
  21. {windhover-0.14.0 → windhover-0.14.1}/windhover/__init__.py +0 -0
  22. {windhover-0.14.0 → windhover-0.14.1}/windhover/config.py +0 -0
  23. {windhover-0.14.0 → windhover-0.14.1}/windhover/demo_graph.py +0 -0
  24. {windhover-0.14.0 → windhover-0.14.1}/windhover/demo_rag.py +0 -0
  25. {windhover-0.14.0 → windhover-0.14.1}/windhover/extract.py +0 -0
  26. {windhover-0.14.0 → windhover-0.14.1}/windhover/pricing.json +0 -0
  27. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/icon.svg +0 -0
  28. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/manifest.json +0 -0
  29. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  30. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/cytoscape.min.js +0 -0
  31. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/dagre.min.js +0 -0
  32. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  33. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  34. {windhover-0.14.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
  35. {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.0
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 the canvas, New run, node source,
64
- and Memory; the Runs page gets an extra graph filter; human-in-the-loop actions always follow
65
- the graph the run belongs to.
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.0"
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
- loadGraph(true); probeMemory(); if(VIEW==='memory') loadMemory(); };
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 ?""", (limit,)).fetchall()]
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, AVG(dur_ms) avg_ms,
377
- SUM(cost_usd) cost FROM spans WHERE type='node'
378
- GROUP BY name ORDER BY avg_ms DESC LIMIT 20""").fetchall()
379
- models = c.execute("""SELECT model, COUNT(*) calls,
380
- SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens,
381
- SUM(cost_usd) cost, AVG(dur_ms) avg_ms
382
- FROM spans WHERE type='llm' AND model IS NOT NULL
383
- GROUP BY model ORDER BY calls DESC LIMIT 20""").fetchall()
384
- daily = c.execute("""SELECT
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