windhover 0.7.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.
@@ -0,0 +1,10 @@
1
+ *.db
2
+ *.db-shm
3
+ *.db-wal
4
+ *.egg-info/
5
+ *.log
6
+ *.pyc
7
+ .env
8
+ __pycache__/
9
+ build/
10
+ dist/
@@ -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.
@@ -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
+ | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
39
+
40
+ | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
41
+ |---|---|
42
+ | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
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,92 @@
1
+ <p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
2
+ <h1 align="center">Windhover</h1>
3
+
4
+ > *Windhover* — the old poetic name for the kestrel, the falcon that hangs motionless
5
+ > in the wind, watching everything below. This tool does the same for your agent graphs.
6
+
7
+ **Self-hosted, mobile-friendly observability for [LangGraph](https://github.com/langchain-ai/langgraph).**
8
+ Trace depth like LangSmith (LLM prompts, tokens, cost, latency — plus retrievers and
9
+ human-in-the-loop interrupts), run history, a timing waterfall, per-node stats, error
10
+ forensics down to the throwing source line — and a **living graph view** that auto-updates
11
+ when your code's topology changes. Point it at any compiled graph, or trace runs in from
12
+ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP + SSE, MIT.
13
+
14
+ > Nothing about your graph's domain is baked in. Topology, the input form, and run
15
+ > outputs all come from the graph itself. Windhover observes — it never edits your graph.
16
+
17
+ | Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
18
+ |---|---|
19
+ | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
20
+
21
+ | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
22
+ |---|---|
23
+ | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
24
+
25
+ ## Quick start
26
+ ```bash
27
+ pip install fastapi uvicorn langgraph
28
+ WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server # -> :8090
29
+ ```
30
+ Open `http://<host>:8090`. **New run** (input pre-filled from the graph's schema) →
31
+ watch it execute → **Runs** for history, span trees, and replay → **Stats** for cost/latency.
32
+ Edit the graph file while it runs and the canvas updates itself.
33
+
34
+ Your own graph: `WINDHOVER_GRAPH="myapp.graphs:g" WINDHOVER_GRAPH_DIR=/path python -m windhover.server`
35
+
36
+ ## Trace runs from any app
37
+ ```python
38
+ from windhover import WindhoverTracer
39
+ graph.invoke(input, config={"callbacks": [WindhoverTracer("http://HOST:8090")]})
40
+ ```
41
+ Node spans, LLM calls (model/prompt/response/tokens/cost), and tools show up in **Runs** —
42
+ wherever your app runs. Non-blocking, best-effort; never raises into your graph.
43
+
44
+ Sessions and tags use standard LangChain config — no Windhover imports needed beyond the tracer:
45
+ ```python
46
+ graph.invoke(input, config={
47
+ "callbacks": [WindhoverTracer("http://HOST:8090")],
48
+ "metadata": {"windhover_session": "chat-42", "windhover_tags": ["prod"]},
49
+ "tags": ["also-captured"], # langgraph-internal tags are filtered out
50
+ })
51
+ ```
52
+
53
+ ## Features
54
+ - **Any graph** — topology from `graph.get_graph()`; input form from its state schema.
55
+ - **Full trace tree** — nodes → nested LLM / tool / **retriever** spans: prompts, responses,
56
+ tokens, cost, latency, retrieved documents with their metadata.
57
+ - **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
58
+ - **Error forensics** — failed runs show the full traceback; the failing node turns red on the
59
+ graph, and the node's source renders with the **throwing line highlighted**.
60
+ - **Human-in-the-loop aware** — a graph paused on `interrupt()` shows an amber **interrupted**
61
+ status plus the payload it's asking a human about.
62
+ - **State evolution** — every trace shows which state keys each node wrote, in order.
63
+ - **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
64
+ (`get_graph(xray=True)`).
65
+ - **Search & filters** — full-text over prompts/payloads/errors (FTS5, LIKE fallback),
66
+ status/tag/session filters, bookmarks, pagination, CSV/JSON export.
67
+ - **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
68
+ - **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
69
+ - **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
70
+ - **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
71
+ - **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
72
+ - **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
73
+
74
+ ## Scores API
75
+ ```bash
76
+ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
77
+ -d '{"name": "accuracy", "value": 0.92, "comment": "vs golden set"}'
78
+ ```
79
+
80
+ ## Config (env)
81
+ `WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
82
+ · `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
83
+ · `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h).
84
+ Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
85
+
86
+ ## Notes
87
+ Runs use the imported graph (restart to run new code); the *view* always reflects
88
+ current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
89
+ Deep links: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
90
+
91
+ ## License
92
+ MIT.
@@ -0,0 +1,83 @@
1
+ # Windhover v0.3 — engineering spec
2
+
3
+ Self-hosted LangGraph observability. Code is the only source of truth; Windhover
4
+ observes. Goal: Langfuse-class trace depth (LLM-level: prompts, tokens, cost,
5
+ latency) + a living graph view that auto-updates when the code's topology changes.
6
+ No visual editing, ever.
7
+
8
+ ## Design principles
9
+ 1. **Observe, never define.** Topology always derives from the compiled graph.
10
+ 2. **The tracer is the single source of span data** — used identically for local
11
+ runs (in-process → DB sink) and external apps (HTTP → ingest). One code path.
12
+ 3. **Runs are durable records.** Execution happens off the request thread; a run
13
+ persists even if the browser disconnects.
14
+ 4. **Degrade gracefully.** No graph configured → ingest-only collector. Unknown
15
+ model → cost null (not a crash). Missing token usage → tokens null.
16
+ 5. **Real package.** pip-installable, typed, smoke-tested. No CDN at runtime once
17
+ vendored (roadmap). MIT.
18
+
19
+ ## Data model (SQLite, WAL)
20
+ `schema_meta(version)`. Migrate v2 `events` → v3 node spans best-effort.
21
+
22
+ `runs`
23
+ id TEXT PK · graph · source(ui|ingest) · status(running|done|error) · session ·
24
+ tags(json) · input(json) · error · started_ms · ended_ms · duration_ms ·
25
+ node_count · llm_calls · prompt_tokens · completion_tokens · total_tokens · cost_usd
26
+
27
+ `spans` (a tree: node spans are top-level; llm/tool spans nest under a node)
28
+ id TEXT PK · run_id · parent_id(nullable) · seq · type(node|llm|tool) · name ·
29
+ status(ok|error) · started_ms · ended_ms · offset_ms(rel to run) · dur_ms ·
30
+ input(json) · output(json) · model · prompt_tokens · completion_tokens ·
31
+ cost_usd · error
32
+ INDEX(run_id, seq)
33
+
34
+ ## Tracer contract (LangChain callback → sink)
35
+ `SpanBuilder(sink, run_name, session=None, tags=None)` emits to `sink(event)`:
36
+ - `{"kind":"run_open", run_id, graph, input, started_ms, session, tags}`
37
+ - `{"kind":"span", ...span row...}` (node, llm, tool)
38
+ - `{"kind":"run_close", run_id, status, ended_ms, error}`
39
+ Sinks: `db_sink(store)` (in-process), `http_sink(base_url)` (WindhoverTracer).
40
+
41
+ Callback facts (LangGraph, verified):
42
+ - node name only on `on_chain_start` via `metadata.langgraph_node` → map run_id→span.
43
+ - llm span: `on_chat_model_start`/`on_llm_start` (model, prompt) →
44
+ `on_llm_end` (LLMResult: `llm_output.token_usage` OR generation
45
+ `usage_metadata`; response text). parent_run_id = enclosing node's run_id.
46
+ - tool span: `on_tool_start`/`on_tool_end`.
47
+ - root = first `on_chain_start` with `parent_run_id is None`.
48
+
49
+ ## Cost
50
+ `pricing.json`: `{ "<model-prefix>": {"input": $/1M, "output": $/1M} }`.
51
+ Longest-prefix match on model name; miss → null. Local bench/subscription models
52
+ have no public price → null by design (matches canary's "est_cost null").
53
+
54
+ ## HTTP API
55
+ GET `/api/graph` topology {nodes,edges,graph,hash}
56
+ GET `/api/schema` input json-schema + blank template
57
+ POST `/api/run` run local graph; SSE (start/node/done/error) + persist spans
58
+ POST `/api/ingest` external tracer events (run_open|span|run_close)
59
+ GET `/api/runs?limit=&session=` history
60
+ GET `/api/runs/{id}` run + span tree
61
+ GET `/api/stats` aggregates (counts, cost/tokens over time, per-node latency, error rate)
62
+ GET `/api/events` SSE: topology-changed pushes (living graph)
63
+
64
+ ## Living graph
65
+ Background watcher polls mtime of graph module + `WINDHOVER_GRAPH_DIR/**.py` (~2s).
66
+ On change → re-extract topology in a **subprocess** (avoids importlib.reload
67
+ fragility) → hash → if changed, broadcast `topology` on `/api/events`. UI (EventSource)
68
+ re-fetches `/api/graph`, re-layouts, toasts "graph changed".
69
+
70
+ ## Frontend (single file, mobile-first PWA)
71
+ Views: **Graph** (topology + live run + live auto-refresh), **Runs** (history w/
72
+ cost+tokens → detail = span tree: node → nested LLM calls w/ model/tokens/cost/
73
+ prompt/response, timing waterfall, replay), **Stats** (strip: runs, cost, tokens,
74
+ error-rate, avg node latency).
75
+
76
+ ## Out of scope (roadmap)
77
+ Vendored deps offline · subgraph drill-in · conditional-edge labels · auth ·
78
+ retention/pruning · export.
79
+
80
+ ## Layout
81
+ windhover/{__init__,config,store,tracer,server,demo_graph}.py · pricing.json ·
82
+ static/{index.html,manifest.json,icon.svg} · tests/test_smoke.py · pyproject.toml ·
83
+ LICENSE · README.md · SPEC.md
Binary file
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
3
+ <stop offset="0" stop-color="#A32C3E"/><stop offset="1" stop-color="#571017"/></linearGradient></defs>
4
+ <rect width="512" height="512" rx="112" fill="url(#bg)"/>
5
+ <g transform="translate(56.0 116.7) scale(0.62893)">
6
+ <path d="M312.90,425.29 C310.64,423.25 306.72,418.86 304.18,415.54 C301.65,412.22 298.88,408.93 298.03,408.23 C295.97,406.53 289.90,407.25 282.33,410.11 C274.09,413.23 271.34,412.33 268.20,405.50 C262.04,392.12 261.63,391.51 258.52,391.16 C256.88,390.98 253.06,391.35 250.02,391.99 C242.01,393.68 239.51,392.45 236.41,385.25 C230.11,370.65 228.49,369.21 220.40,371.02 C217.79,371.61 214.35,371.80 212.76,371.45 C209.08,370.64 206.01,365.75 206.00,360.69 C206.00,357.52 206.81,356.01 210.82,351.69 C213.47,348.84 217.71,343.80 220.23,340.50 C222.75,337.20 226.92,332.48 229.51,330.00 C232.09,327.52 236.81,322.12 240.01,318.00 C243.20,313.88 249.27,307.12 253.49,303.00 C257.72,298.88 263.24,292.80 265.78,289.50 C268.31,286.20 272.76,280.90 275.66,277.73 C282.46,270.28 283.78,266.15 281.64,258.90 C278.68,248.89 277.41,247.67 267.38,245.23 C260.71,243.60 251.53,239.67 230.50,229.45 C215.10,221.96 200.47,214.74 198.00,213.41 C195.53,212.08 184.95,206.76 174.50,201.60 C164.05,196.44 153.25,191.06 150.50,189.65 C147.75,188.24 137.08,182.79 126.78,177.55 C114.19,171.14 107.49,167.16 106.33,165.38 C104.65,162.82 104.67,162.68 106.91,160.87 C108.24,159.80 110.80,159.00 112.91,159.00 C116.69,159.00 119.00,157.60 119.00,155.31 C119.00,154.60 114.84,151.96 109.75,149.44 C99.17,144.20 67.99,123.81 66.21,120.97 C65.56,119.93 65.31,118.35 65.66,117.45 C66.19,116.07 67.32,115.93 73.06,116.51 C81.49,117.35 84.00,116.65 84.00,113.45 C84.00,111.44 81.40,109.29 68.75,100.84 C52.97,90.30 37.70,78.18 35.86,74.74 C35.05,73.23 35.18,72.40 36.40,71.18 C37.85,69.72 38.96,69.82 49.38,72.36 C62.67,75.61 67.35,75.59 67.81,72.29 C68.05,70.60 65.53,67.46 57.31,59.22 C46.57,48.44 41.78,42.47 40.57,38.36 C39.71,35.46 42.49,32.71 45.36,33.60 C46.53,33.97 60.99,41.01 77.47,49.25 C93.96,57.49 112.41,66.03 118.47,68.23 C146.15,78.27 183.73,90.19 202.92,95.00 C219.11,99.06 221.00,99.92 227.79,106.30 C230.93,109.26 235.53,113.06 238.00,114.75 C240.47,116.45 245.43,120.83 249.00,124.50 C252.57,128.17 257.52,132.55 260.00,134.25 C262.48,135.94 267.05,139.73 270.18,142.66 C277.53,149.58 279.88,149.68 287.17,143.41 C290.10,140.89 295.20,137.43 298.50,135.71 C303.95,132.88 305.53,132.56 315.72,132.19 C331.48,131.62 338.30,133.73 347.42,142.00 C356.10,149.87 358.09,149.94 365.82,142.66 C368.95,139.73 373.52,135.94 376.00,134.25 C378.48,132.55 383.43,128.17 387.00,124.49 C390.57,120.82 395.98,116.11 399.00,114.01 C402.02,111.92 406.07,108.54 408.00,106.51 C412.56,101.70 416.56,99.78 427.50,97.09 C442.99,93.30 489.94,78.25 517.53,68.23 C523.59,66.03 542.04,57.49 558.53,49.25 C575.01,41.01 589.47,33.97 590.64,33.60 C592.03,33.17 593.37,33.51 594.43,34.57 C597.63,37.77 594.44,42.92 579.49,58.73 C573.72,64.83 569.00,70.52 569.00,71.38 C569.00,74.33 572.54,75.17 578.74,73.67 C591.83,70.50 597.95,69.72 599.51,71.01 C603.83,74.59 599.88,78.43 573.00,96.80 C566.67,101.12 558.72,106.13 555.32,107.93 C548.71,111.43 547.22,113.35 549.22,115.76 C550.25,117.00 552.19,117.16 560.13,116.64 C568.29,116.10 569.88,116.24 570.38,117.54 C570.70,118.39 570.44,119.93 569.79,120.97 C568.01,123.81 536.83,144.20 526.25,149.44 C521.16,151.96 517.00,154.60 517.00,155.31 C517.00,157.60 519.31,159.00 523.09,159.00 C525.20,159.00 527.76,159.80 529.09,160.87 C533.98,164.83 532.25,165.87 461.87,201.06 C425.53,219.23 394.15,235.20 392.12,236.54 C388.01,239.26 374.07,244.22 365.58,245.98 C361.45,246.84 359.57,247.82 358.19,249.82 C355.43,253.80 353.00,260.60 353.00,264.32 C353.00,267.82 357.09,275.09 360.98,278.50 C362.23,279.60 365.86,283.88 369.04,288.00 C372.21,292.12 378.27,298.88 382.50,303.00 C386.73,307.12 392.80,313.88 396.00,318.00 C399.19,322.12 404.37,327.98 407.50,331.00 C415.03,338.28 427.28,352.69 429.35,356.70 C431.73,361.32 431.39,364.53 428.13,368.18 C425.01,371.67 422.07,372.14 412.93,370.56 C406.63,369.48 403.40,372.27 401.99,380.00 C400.84,386.26 398.56,389.89 394.54,391.86 C392.28,392.97 390.71,393.00 386.11,392.02 C383.00,391.36 379.12,390.98 377.48,391.16 C374.37,391.51 373.96,392.12 367.80,405.50 C364.66,412.33 361.91,413.23 353.67,410.11 C346.10,407.25 340.03,406.53 337.97,408.23 C337.12,408.93 334.35,412.22 331.82,415.54 C326.95,421.91 319.68,429.00 318.00,429.00 C317.45,429.00 315.16,427.33 312.90,425.29 z M323.60,201.25 C329.52,190.76 330.86,189.21 336.20,186.68 C344.02,182.98 348.00,178.02 348.00,172.00 C348.00,168.25 346.43,165.00 344.62,165.00 C343.90,165.00 340.29,167.93 336.61,171.50 C332.93,175.07 329.50,178.00 328.99,178.00 C328.48,178.00 326.47,180.49 324.52,183.54 C322.57,186.58 320.28,189.34 319.42,189.67 C317.02,190.59 313.64,188.28 310.95,183.86 C308.20,179.35 294.66,167.00 292.47,167.00 C289.94,167.00 287.69,171.70 288.33,175.64 C289.11,180.46 291.29,182.65 298.80,186.21 C305.24,189.26 306.28,190.41 312.40,201.25 C314.64,205.22 315.56,206.00 318.00,206.00 C320.44,206.00 321.36,205.22 323.60,201.25 z" fill="#fff"/>
7
+ </g>
8
+ </svg>
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "windhover"
7
+ version = "0.7.0"
8
+ description = "Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "justfeltlikerunning" }]
13
+ keywords = ["langgraph", "langchain", "observability", "tracing", "llm", "agents"]
14
+ dependencies = ["fastapi>=0.110", "uvicorn>=0.29"]
15
+
16
+ [project.optional-dependencies]
17
+ demo = ["langgraph>=0.2"]
18
+ dev = ["pytest", "langgraph>=0.2"]
19
+
20
+ [project.scripts]
21
+ windhover = "windhover.server:main"
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/justfeltlikerunning/windhover"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["windhover"]
File without changes
@@ -0,0 +1,259 @@
1
+ """Windhover smoke tests — store roundtrip, cost math, tracer against a real
2
+ LangGraph run (incl. a fake LLM node to exercise LLM-span capture). Run:
3
+
4
+ python -m pytest tests/ -q (or: python tests/test_smoke.py)
5
+ """
6
+ import json, os, sys, tempfile, time
7
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
8
+
9
+ from windhover.store import Store
10
+ from windhover.tracer import SpanBuilder, db_sink, cost_of
11
+
12
+
13
+ def test_cost():
14
+ assert cost_of("gpt-4o", 1_000_000, 1_000_000) == 12.5 # 2.5 + 10
15
+ assert cost_of("gpt-4o-2024-11-20", 1_000_000, 0) == 2.5 # prefix match
16
+ assert cost_of("bench/fable", 1000, 1000) is None # unknown -> None
17
+ assert cost_of(None, 1, 1) is None
18
+
19
+
20
+ def test_store_roundtrip():
21
+ p = tempfile.mktemp(suffix=".db")
22
+ s = Store(p)
23
+ s.open_run({"id": "r1", "graph": "g", "source": "ui", "started_ms": 1000})
24
+ s.add_span({"id": "n1", "run_id": "r1", "type": "node", "name": "a",
25
+ "seq": 0, "offset_ms": 0, "dur_ms": 10, "output": {"x": 1}})
26
+ s.add_span({"id": "l1", "run_id": "r1", "parent_id": "n1", "type": "llm",
27
+ "name": "gpt-4o", "seq": 1, "dur_ms": 5,
28
+ "prompt_tokens": 1_000_000, "completion_tokens": 0, "cost_usd": 2.5})
29
+ s.close_run("r1", "done", 1050)
30
+ d = s.run_detail("r1")
31
+ assert d["status"] == "done" and d["duration_ms"] == 50
32
+ assert d["node_count"] == 1 and d["llm_calls"] == 1
33
+ assert abs(d["cost_usd"] - 2.5) < 1e-9
34
+ assert len(d["spans"]) == 2
35
+ llm = [x for x in d["spans"] if x["type"] == "llm"][0]
36
+ assert llm["parent_id"] == "n1"
37
+ print("store roundtrip OK")
38
+
39
+
40
+ def test_tracer_local_graph():
41
+ from typing import TypedDict
42
+ from langgraph.graph import StateGraph, START, END
43
+ p = tempfile.mktemp(suffix=".db")
44
+ s = Store(p)
45
+
46
+ class St(TypedDict):
47
+ x: int
48
+ def a(st): return {"x": st["x"] + 1}
49
+ def b(st): return {"x": st["x"] * 10}
50
+ g = StateGraph(St)
51
+ g.add_node("inc", a); g.add_node("mul", b)
52
+ g.add_edge(START, "inc"); g.add_edge("inc", "mul"); g.add_edge("mul", END)
53
+ app = g.compile()
54
+
55
+ tracer = SpanBuilder(db_sink(s), run_name="test")
56
+ out = app.invoke({"x": 4}, config={"callbacks": [tracer]})
57
+ assert out["x"] == 50
58
+ time.sleep(.1)
59
+ d = s.run_detail(tracer.run_id)
60
+ assert d is not None and d["status"] == "done"
61
+ names = [sp["name"] for sp in d["spans"] if sp["type"] == "node"]
62
+ assert "inc" in names and "mul" in names
63
+ print("tracer local-graph OK — nodes:", names)
64
+
65
+
66
+ def test_search_filters_scores_sessions():
67
+ p = tempfile.mktemp(suffix=".db")
68
+ s = Store(p)
69
+ now = int(time.time() * 1000)
70
+ for i, (sess, tag) in enumerate([("chat-1", "alpha"), ("chat-1", "beta"), (None, "alpha")]):
71
+ rid = f"r{i}"
72
+ s.open_run({"id": rid, "graph": "g", "source": "ui", "session": sess,
73
+ "tags": [tag], "started_ms": now - i * 1000})
74
+ s.add_span({"id": f"n{i}", "run_id": rid, "type": "node", "name": "work",
75
+ "seq": 0, "dur_ms": 5, "output": {"text": f"needle-{i} haystack"}})
76
+ s.close_run(rid, "done" if i else "error", now - i * 1000 + 50)
77
+ # payload search (FTS5 or LIKE fallback — same contract either way)
78
+ hit = s.runs(q=f"needle-1")
79
+ assert hit["total"] == 1 and hit["runs"][0]["id"] == "r1"
80
+ assert s.runs(status="error")["total"] == 1
81
+ assert s.runs(tag="alpha")["total"] == 2
82
+ assert s.runs(session="chat-1")["total"] == 2
83
+ ss = s.sessions()
84
+ assert len(ss) == 1 and ss[0]["session"] == "chat-1" and ss[0]["runs"] == 2
85
+ assert ss[0]["errors"] == 1
86
+ # bookmarks
87
+ assert s.update_run_meta("r0", bookmarked=True)
88
+ assert s.runs(bookmarked=True)["total"] == 1
89
+ assert not s.update_run_meta("nope", bookmarked=True)
90
+ # scores: add, aggregate onto runs page, embed in detail, delete
91
+ sc = s.add_score("r0", "accuracy", 0.9, comment="ok", source="test")
92
+ assert sc is not None
93
+ assert s.add_score("missing-run", "x", 1.0) is None
94
+ assert s.runs(bookmarked=True)["runs"][0]["scores"] == {"accuracy": 0.9}
95
+ assert s.run_detail("r0")["scores"][0]["name"] == "accuracy"
96
+ assert s.delete_score(sc["id"])
97
+ # pagination
98
+ pg = s.runs(limit=2, offset=2)
99
+ assert pg["total"] == 3 and len(pg["runs"]) == 1
100
+ print("search/filters/scores/sessions OK (fts=%s json1=%s)" % (s.has_fts, s.has_json1))
101
+
102
+
103
+ def test_prune_and_stats():
104
+ p = tempfile.mktemp(suffix=".db")
105
+ s = Store(p)
106
+ old = int((time.time() - 40 * 86400) * 1000)
107
+ now = int(time.time() * 1000)
108
+ s.open_run({"id": "old1", "graph": "g", "started_ms": old})
109
+ s.close_run("old1", "done", old + 10)
110
+ s.open_run({"id": "new1", "graph": "g", "started_ms": now})
111
+ s.add_span({"id": "L1", "run_id": "new1", "type": "llm", "name": "m", "seq": 0,
112
+ "dur_ms": 3, "model": "gpt-4o", "prompt_tokens": 10, "completion_tokens": 5})
113
+ s.close_run("new1", "done", now + 10)
114
+ st = s.stats(days=30)
115
+ assert st["models"] and st["models"][0]["model"] == "gpt-4o"
116
+ assert any(d["runs"] for d in st["daily"])
117
+ res = s.prune(30)
118
+ assert res["pruned_runs"] == 1
119
+ assert s.run_detail("old1") is None and s.run_detail("new1") is not None
120
+ print("prune/stats OK")
121
+
122
+
123
+ def test_tracer_metadata_session_tags():
124
+ """The generic wire-level idiom: config metadata/tags from ANY LangChain app."""
125
+ p = tempfile.mktemp(suffix=".db")
126
+ s = Store(p)
127
+ tr = SpanBuilder(db_sink(s), run_name="t")
128
+ tr.on_chain_start({}, {"x": 1}, run_id="root1", parent_run_id=None,
129
+ metadata={"windhover_session": "sess-42", "windhover_tags": ["custom"]},
130
+ tags=["graph:step:1", "langsmith:hidden", "mytag"])
131
+ tr.on_chain_end({}, run_id="root1")
132
+ d = s.run_detail(tr.run_id)
133
+ assert d is not None and d["session"] == "sess-42"
134
+ assert set(d["tags"]) == {"custom", "mytag"} # internal langgraph tags filtered
135
+ print("tracer metadata session/tags OK")
136
+
137
+
138
+ def _tiny_graph(fail=False):
139
+ from typing import TypedDict
140
+ from langgraph.graph import StateGraph, START, END
141
+
142
+ class St(TypedDict):
143
+ x: int
144
+
145
+ def boom(st):
146
+ raise RuntimeError("kaboom from boom()")
147
+
148
+ def inc(st):
149
+ return {"x": st["x"] + 1}
150
+
151
+ g = StateGraph(St)
152
+ g.add_node("inc", boom if fail else inc)
153
+ g.add_edge(START, "inc"); g.add_edge("inc", END)
154
+ return g.compile()
155
+
156
+
157
+ def test_source_extraction():
158
+ from windhover.extract import sources
159
+ app = _tiny_graph()
160
+ src = sources(app)
161
+ assert "inc" in src, f"no source for inc: {list(src)}"
162
+ s = src["inc"]
163
+ assert "def inc(st):" in s["code"]
164
+ assert s["file"].endswith(".py") and s["line_start"] > 0
165
+ assert s["line_end"] >= s["line_start"]
166
+ print("source extraction OK —", s["file"].split("/")[-1], f"L{s['line_start']}")
167
+
168
+
169
+ def test_error_traceback_capture():
170
+ p = tempfile.mktemp(suffix=".db")
171
+ s = Store(p)
172
+ app = _tiny_graph(fail=True)
173
+ tracer = SpanBuilder(db_sink(s), run_name="failing")
174
+ try:
175
+ app.invoke({"x": 1}, config={"callbacks": [tracer]})
176
+ assert False, "graph should have raised"
177
+ except RuntimeError:
178
+ pass
179
+ time.sleep(.1)
180
+ d = s.run_detail(tracer.run_id)
181
+ assert d is not None and d["status"] == "error"
182
+ err_spans = [sp for sp in d["spans"] if sp["status"] == "error" and sp["type"] == "node"]
183
+ assert err_spans, "no error node span recorded"
184
+ err = err_spans[0]["error"]
185
+ # full traceback with file+line so the UI can highlight the source line
186
+ assert "Traceback" in err and 'File "' in err and "kaboom from boom()" in err
187
+ assert d["error"] and "kaboom" in d["error"]
188
+ print("error traceback capture OK")
189
+
190
+
191
+ def test_retriever_spans():
192
+ p = tempfile.mktemp(suffix=".db")
193
+ s = Store(p)
194
+ tr = SpanBuilder(db_sink(s), run_name="rag")
195
+ tr.on_chain_start({}, {"q": "hi"}, run_id="root", parent_run_id=None)
196
+
197
+ class Doc:
198
+ def __init__(self, c): self.page_content, self.metadata = c, {"src": "kb.md"}
199
+ tr.on_retriever_start({"name": "VectorStoreRetriever"}, "pricing policy",
200
+ run_id="ret1", parent_run_id="root")
201
+ tr.on_retriever_end([Doc("alpha"), Doc("beta")], run_id="ret1")
202
+ tr.on_chain_end({}, run_id="root")
203
+ d = s.run_detail(tr.run_id)
204
+ ret = [sp for sp in d["spans"] if sp["type"] == "retriever"]
205
+ assert ret and ret[0]["name"] == "VectorStoreRetriever"
206
+ assert ret[0]["input"] == "pricing policy"
207
+ assert ret[0]["output"]["count"] == 2
208
+ assert ret[0]["output"]["documents"][0]["content"] == "alpha"
209
+ print("retriever spans OK")
210
+
211
+
212
+ def test_interrupt_status():
213
+ p = tempfile.mktemp(suffix=".db")
214
+ s = Store(p)
215
+ tr = SpanBuilder(db_sink(s), run_name="hitl")
216
+ tr.on_chain_start({}, {"x": 1}, run_id="root", parent_run_id=None)
217
+ tr.on_chain_end({"__interrupt__": [{"value": "approve the wire transfer?"}]}, run_id="root")
218
+ d = s.run_detail(tr.run_id)
219
+ assert d["status"] == "interrupted", d["status"]
220
+ ints = [sp for sp in d["spans"] if sp["type"] == "interrupt"]
221
+ assert ints and "approve the wire transfer?" in json.dumps(ints[0]["output"])
222
+ print("interrupt status OK")
223
+
224
+
225
+ def test_xray_topology():
226
+ from typing import TypedDict
227
+ from langgraph.graph import StateGraph, START, END
228
+ from windhover.extract import topology
229
+
230
+ class St(TypedDict):
231
+ x: int
232
+ child = StateGraph(St)
233
+ child.add_node("inner_a", lambda st: {"x": st["x"] + 1})
234
+ child.add_node("inner_b", lambda st: {"x": st["x"] * 2})
235
+ child.add_edge(START, "inner_a"); child.add_edge("inner_a", "inner_b")
236
+ child.add_edge("inner_b", END)
237
+ parent = StateGraph(St)
238
+ parent.add_node("child", child.compile())
239
+ parent.add_edge(START, "child"); parent.add_edge("child", END)
240
+ app = parent.compile()
241
+ plain, xray = topology(app), topology(app, xray=True)
242
+ assert len(xray["nodes"]) > len(plain["nodes"]), (len(plain["nodes"]), len(xray["nodes"]))
243
+ assert any("inner_a" in n["id"] for n in xray["nodes"])
244
+ print("xray topology OK —", len(plain["nodes"]), "->", len(xray["nodes"]), "nodes")
245
+
246
+
247
+ if __name__ == "__main__":
248
+ test_cost(); print("cost OK")
249
+ test_store_roundtrip()
250
+ test_tracer_local_graph()
251
+ test_search_filters_scores_sessions()
252
+ test_prune_and_stats()
253
+ test_tracer_metadata_session_tags()
254
+ test_source_extraction()
255
+ test_error_traceback_capture()
256
+ test_retriever_spans()
257
+ test_interrupt_status()
258
+ test_xray_topology()
259
+ print("ALL SMOKE TESTS PASSED")
@@ -0,0 +1,5 @@
1
+ """Windhover — self-hosted LangGraph observability."""
2
+ from .tracer import WindhoverTracer, SpanBuilder
3
+
4
+ __version__ = "0.3.0"
5
+ __all__ = ["WindhoverTracer", "SpanBuilder", "__version__"]