driftcast 1.0.0__py3-none-any.whl

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.
driftcast/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """driftcast — cost and trace telemetry for agent frameworks."""
2
+
3
+ from .storage import default_db_path
4
+ from .tracer import Lens, annotate, outcome, record
5
+
6
+ __all__ = ["init", "Lens", "record", "annotate", "outcome", "default_db_path"]
7
+
8
+
9
+ def init(
10
+ project: str,
11
+ db_path: str | None = None,
12
+ capture_content: bool = False,
13
+ ) -> Lens:
14
+ """Single entry point so every caller gets the universal db path and the capture_content privacy default (False = shape and economics only, never content)."""
15
+ if db_path is None:
16
+ db_path = default_db_path()
17
+ return Lens(project=project, db_path=db_path, capture_content=capture_content)
driftcast/cli.py ADDED
@@ -0,0 +1,128 @@
1
+ """
2
+ cli.py — `driftcast summary`: aggregate cost/token report.
3
+
4
+ Plain-table output, mirroring the visual style of rag-agent's
5
+ `ingest.py:preview_ingest` for consistency across the DriftCast tooling.
6
+ """
7
+
8
+ import argparse
9
+ import sqlite3
10
+
11
+ from .storage import default_db_path
12
+
13
+
14
+ def _fetch_rows(db_path: str, project: str | None):
15
+ """Isolates the read-only SQL so the printers work on plain rows."""
16
+ conn = sqlite3.connect(db_path)
17
+ conn.row_factory = sqlite3.Row
18
+ try:
19
+ if project:
20
+ runs = conn.execute(
21
+ "SELECT * FROM runs WHERE project = ? ORDER BY started_at", (project,)
22
+ ).fetchall()
23
+ else:
24
+ runs = conn.execute("SELECT * FROM runs ORDER BY started_at").fetchall()
25
+
26
+ run_ids = [r["run_id"] for r in runs]
27
+ spans = []
28
+ if run_ids:
29
+ placeholders = ",".join("?" * len(run_ids))
30
+ spans = conn.execute(
31
+ f"SELECT * FROM spans WHERE run_id IN ({placeholders})", run_ids
32
+ ).fetchall()
33
+ return runs, spans
34
+ finally:
35
+ conn.close()
36
+
37
+
38
+ def _print_by_run(runs):
39
+ """Answers "what did each pipeline execution cost" at a glance in the terminal."""
40
+ if not runs:
41
+ print("No runs recorded.")
42
+ return
43
+
44
+ col = 28
45
+ print(f"\n {'Run':<{col}} {'Status':>8} {'Cost ($)':>10} {'Started':>26}")
46
+ print(" " + "─" * (col + 48))
47
+ total_cost = 0.0
48
+ for r in runs:
49
+ print(f" {r['name']:<{col}} {r['status']:>8} {r['total_cost']:>10.4f} {r['started_at']:>26}")
50
+ total_cost += r["total_cost"]
51
+ print(" " + "─" * (col + 48))
52
+ print(f" {'TOTAL':<{col}} {'':>8} {total_cost:>10.4f}")
53
+ print()
54
+
55
+
56
+ def _print_by_model(spans):
57
+ """Answers "which model is the money going to" without opening the dashboard."""
58
+ if not spans:
59
+ return
60
+
61
+ stats: dict[str, dict] = {}
62
+ for s in spans:
63
+ model = s["model"] or "(no model)"
64
+ st = stats.setdefault(model, {"calls": 0, "input_tokens": 0, "output_tokens": 0, "cost": 0.0, "latency_ms": 0.0})
65
+ st["calls"] += 1
66
+ st["input_tokens"] += s["input_tokens"]
67
+ st["output_tokens"] += s["output_tokens"]
68
+ st["cost"] += s["cost"]
69
+ st["latency_ms"] += s["latency_ms"]
70
+
71
+ col = 28
72
+ print(f" {'Model':<{col}} {'Calls':>6} {'In tok':>10} {'Out tok':>10} {'Cost ($)':>10} {'Avg ms':>8}")
73
+ print(" " + "─" * (col + 48))
74
+ total_cost = total_calls = 0
75
+ for model, st in stats.items():
76
+ avg_latency = st["latency_ms"] / st["calls"]
77
+ print(f" {model:<{col}} {st['calls']:>6,} {st['input_tokens']:>10,} {st['output_tokens']:>10,} {st['cost']:>10.4f} {avg_latency:>8.0f}")
78
+ total_cost += st["cost"]
79
+ total_calls += st["calls"]
80
+ print(" " + "─" * (col + 48))
81
+ print(f" {'TOTAL':<{col}} {total_calls:>6,}")
82
+ print()
83
+
84
+
85
+ def summary(db_path: str, project: str | None = None) -> None:
86
+ """Gives scripts and the CLI one callable for the full cost/token report."""
87
+ runs, spans = _fetch_rows(db_path, project)
88
+ label = f" for project '{project}'" if project else ""
89
+ print(f"\nDriftCast summary{label} — {db_path}")
90
+ _print_by_run(runs)
91
+ _print_by_model(spans)
92
+
93
+
94
+ def main() -> None:
95
+ """Console-script entry point: routes `driftcast <command>` to the right tool with lazy optional-extra imports."""
96
+ parser = argparse.ArgumentParser(prog="driftcast")
97
+ sub = parser.add_subparsers(dest="command", required=True)
98
+
99
+ summary_parser = sub.add_parser("summary", help="Aggregate cost/token report")
100
+ summary_parser.add_argument("--db", default=None,
101
+ help="Path to the SQLite db (default: the universal db via default_db_path())")
102
+ summary_parser.add_argument("--project", default=None, help="Filter by project name")
103
+
104
+ dash_parser = sub.add_parser("dashboard", help="Live web dashboard (needs the 'dashboard' extra)")
105
+ dash_parser.add_argument("--db", default=None,
106
+ help="Path to the SQLite db (default: the universal db via default_db_path())")
107
+ dash_parser.add_argument("--project", default=None, help="Filter by project name")
108
+ dash_parser.add_argument("--port", type=int, default=7861, help="Port to serve on")
109
+ dash_parser.add_argument("--refresh", type=float, default=2.0, help="Auto-refresh interval (seconds)")
110
+
111
+ args = parser.parse_args()
112
+ if args.command == "summary":
113
+ summary(args.db or default_db_path(), args.project)
114
+ elif args.command == "dashboard":
115
+ try:
116
+ from . import dashboard
117
+ except ImportError:
118
+ raise SystemExit(
119
+ "The dashboard needs Gradio. Install it with: pip install \"driftcast[dashboard]\""
120
+ )
121
+ dashboard.launch(
122
+ db_path=args.db or default_db_path(), project=args.project, port=args.port,
123
+ refresh_seconds=args.refresh, block=True,
124
+ )
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
driftcast/dashboard.py ADDED
@@ -0,0 +1,329 @@
1
+ """
2
+ dashboard.py — live Gradio dashboard for DriftCast stats.
3
+
4
+ Reads the same SQLite db the tracer writes to and renders a live-refreshing
5
+ view of runs, per-model cost/token aggregates, and headline totals. Reusable
6
+ by any agent that uses the SDK — point it at the project's db and launch.
7
+
8
+ The "Coordination" tab renders each run as a tree from parent_span_id, so a
9
+ multi-agent run (orchestrator → chosen subagent → provider calls) reads as a
10
+ diagram of what the system did and what each step cost — the routing LLM calls
11
+ (route_classify / judge_answer / synthesize) included, since those spend tokens
12
+ too. When a run records routing fields via driftcast.annotate() (route / source /
13
+ reason / refined), a one-line badge explains WHY that run went where it did.
14
+
15
+ Gradio is an *optional* dependency, imported lazily inside the launch path so
16
+ the core SDK (tracer/storage/pricing) stays stdlib-only. Install with:
17
+
18
+ pip install "driftcast[dashboard]"
19
+ """
20
+
21
+ import json
22
+ import sqlite3
23
+
24
+ # Absolute (not `from .storage`) so this module works both as `python -m
25
+ # driftcast.dashboard` AND run directly as a file (`python dashboard.py`), where
26
+ # there is no parent package for a relative import to resolve against. driftcast
27
+ # is pip-installed, so the absolute form resolves in either case.
28
+ from driftcast.storage import default_db_path
29
+
30
+ # ── Read layer (read-only; never writes) ──────────────────────────────────────
31
+
32
+
33
+ def _fetch(db_path: str, project: str | None):
34
+ """Return (runs, spans) as lists of sqlite3.Row. Empty lists if no data yet."""
35
+ conn = sqlite3.connect(db_path)
36
+ conn.row_factory = sqlite3.Row
37
+ try:
38
+ if project:
39
+ runs = conn.execute(
40
+ "SELECT * FROM runs WHERE project = ? ORDER BY started_at DESC", (project,)
41
+ ).fetchall()
42
+ else:
43
+ runs = conn.execute("SELECT * FROM runs ORDER BY started_at DESC").fetchall()
44
+
45
+ run_ids = [r["run_id"] for r in runs]
46
+ spans = []
47
+ if run_ids:
48
+ placeholders = ",".join("?" * len(run_ids))
49
+ spans = conn.execute(
50
+ f"SELECT * FROM spans WHERE run_id IN ({placeholders}) ORDER BY started_at",
51
+ run_ids,
52
+ ).fetchall()
53
+ return runs, spans
54
+ except sqlite3.OperationalError:
55
+ # Tables not created yet (agent hasn't run a single trace) — show empty.
56
+ return [], []
57
+ finally:
58
+ conn.close()
59
+
60
+
61
+ def _col(row: sqlite3.Row, key: str, default=None):
62
+ """Read a column that may be absent on a db written by an older SDK build."""
63
+ return row[key] if key in row.keys() else default
64
+
65
+
66
+ # ── Coordination tree (per-run, built from parent_span_id) ────────────────────
67
+
68
+ # Routing reason codes (set by the orchestrator via annotate) → human text. Kept
69
+ # here so the badge reads cleanly; an unknown code falls back to itself with
70
+ # underscores turned into spaces, so new codes still render.
71
+ _REASON_TEXT = {
72
+ "intent_public_web": "classifier judged the query needs current public web info",
73
+ "no_corpus": "no local corpus to ground an answer — fell back to web",
74
+ "rag_sufficient": "RAG answer judged sufficient on its own",
75
+ "rag_insufficient_refined": "RAG answer judged weak — refined with web evidence",
76
+ }
77
+
78
+
79
+ def _routing_badge(metadata_json: str | None) -> str | None:
80
+ """Build a one-line 'why' badge from a run's annotated routing metadata."""
81
+ if not metadata_json:
82
+ return None
83
+ try:
84
+ meta = json.loads(metadata_json)
85
+ except (ValueError, TypeError):
86
+ return None
87
+ source = meta.get("source") or meta.get("route")
88
+ if not source:
89
+ return None # not a routed run — nothing to explain
90
+
91
+ parts = [f"**routed to {str(source).upper()}**"]
92
+ reason = meta.get("reason")
93
+ if reason:
94
+ parts.append(_REASON_TEXT.get(reason, str(reason).replace("_", " ")))
95
+ if meta.get("refined"):
96
+ parts.append("RAG base + web evidence synthesized")
97
+ return " · ".join(parts)
98
+
99
+
100
+ def _fmt_span_line(s: sqlite3.Row, depth: int) -> str:
101
+ """One monospaced tree row for a span: name · model · tokens · cost [· error]."""
102
+ connector = (" " * (depth - 1) + "└─ ") if depth else ""
103
+ model = s["model"] or "—"
104
+ toks = f"{s['input_tokens']} in / {s['output_tokens']} out"
105
+ line = f"{connector}{s['name']} · {model} · {toks} · ${s['cost']:.4f}"
106
+ if s["status"] != "ok":
107
+ line += f" · [{s['status']}: {s['error']}]"
108
+ return line
109
+
110
+
111
+ def _render_run(run: sqlite3.Row, run_spans: list[sqlite3.Row]) -> str:
112
+ """Render one run as a header + routing badge + indented span tree."""
113
+ mark = "ok" if run["status"] == "ok" else "ERROR"
114
+ header = (
115
+ f"#### {run['name']} · {mark} · ${run['total_cost']:.4f} "
116
+ f"· {run['started_at']}"
117
+ )
118
+
119
+ blocks = [header]
120
+ badge = _routing_badge(_col(run, "metadata_json"))
121
+ if badge:
122
+ blocks.append(badge)
123
+
124
+ # Group children by parent, then walk depth-first from the roots (parent None).
125
+ children: dict[str | None, list[sqlite3.Row]] = {}
126
+ for s in run_spans:
127
+ children.setdefault(_col(s, "parent_span_id"), []).append(s)
128
+
129
+ lines: list[str] = []
130
+
131
+ def walk(parent_id: str | None, depth: int) -> None:
132
+ """Emits span rows depth-first so the tree indents match parent_span_id nesting."""
133
+ for s in children.get(parent_id, []):
134
+ lines.append(_fmt_span_line(s, depth))
135
+ walk(s["span_id"], depth + 1)
136
+
137
+ walk(None, 0)
138
+ blocks.append("```\n" + "\n".join(lines) + "\n```" if lines else "_(no provider calls recorded)_")
139
+ return "\n\n".join(blocks)
140
+
141
+
142
+ def _coordination_md(runs, spans) -> str:
143
+ """Renders the whole Coordination tab as one tree per agent, newest-first, so activity reads top-to-bottom without a sort step."""
144
+ if not runs:
145
+ return "_No runs recorded yet._"
146
+ by_run: dict[str, list] = {}
147
+ for s in spans:
148
+ by_run.setdefault(s["run_id"], []).append(s)
149
+
150
+ by_project: dict[str, list] = {}
151
+ for r in runs:
152
+ by_project.setdefault(_col(r, "project") or "(no project)", []).append(r)
153
+
154
+ sections = []
155
+ for project, project_runs in by_project.items():
156
+ n = len(project_runs)
157
+ header = f"## {project} · {n} run{'s' if n != 1 else ''}"
158
+ run_blocks = [_render_run(r, by_run.get(r["run_id"], [])) for r in project_runs]
159
+ sections.append(header + "\n\n" + "\n\n---\n\n".join(run_blocks))
160
+ return "\n\n<br>\n\n".join(sections)
161
+
162
+
163
+ # ── Snapshot (the view payloads) ──────────────────────────────────────────────
164
+
165
+
166
+ def _snapshot(db_path: str, project: str | None):
167
+ """Build the view payloads: headline markdown, coordination markdown, model rows."""
168
+ runs, spans = _fetch(db_path, project)
169
+
170
+ total_cost = sum(r["total_cost"] for r in runs)
171
+ total_runs = len(runs)
172
+ errored = sum(1 for r in runs if r["status"] == "error")
173
+ total_in = sum(s["input_tokens"] for s in spans)
174
+ total_out = sum(s["output_tokens"] for s in spans)
175
+
176
+ label = f"`{project}`" if project else "all projects"
177
+ headline = (
178
+ f"### DriftCast — {label}\n"
179
+ f"**${total_cost:.4f}** total · "
180
+ f"**{total_runs}** runs ({errored} errored) · "
181
+ f"**{total_in:,}** in / **{total_out:,}** out tokens\n\n"
182
+ f"<sub>live, refreshes automatically</sub>"
183
+ )
184
+
185
+ coordination_md = _coordination_md(runs, spans)
186
+
187
+ stats: dict[str, dict] = {}
188
+ for s in spans:
189
+ model = s["model"] or "(no model)"
190
+ st = stats.setdefault(
191
+ model, {"calls": 0, "in": 0, "out": 0, "cost": 0.0, "latency_ms": 0.0}
192
+ )
193
+ st["calls"] += 1
194
+ st["in"] += s["input_tokens"]
195
+ st["out"] += s["output_tokens"]
196
+ st["cost"] += s["cost"]
197
+ st["latency_ms"] += s["latency_ms"]
198
+
199
+ model_rows = [
200
+ [
201
+ model,
202
+ st["calls"],
203
+ f"{st['in']:,}",
204
+ f"{st['out']:,}",
205
+ f"{st['cost']:.4f}",
206
+ f"{st['latency_ms'] / st['calls']:.0f}",
207
+ ]
208
+ for model, st in sorted(stats.items(), key=lambda kv: kv[1]["cost"], reverse=True)
209
+ ]
210
+
211
+ return headline, coordination_md, model_rows
212
+
213
+
214
+ # ── Gradio app ────────────────────────────────────────────────────────────────
215
+
216
+
217
+ def build_app(db_path: str, project: str | None = None, refresh_seconds: float = 2.0):
218
+ """Build (don't launch) the Gradio dashboard app."""
219
+ import gradio as gr
220
+
221
+ def refresh():
222
+ """Rebuilds the view payloads on each Gradio tick so the dashboard stays live."""
223
+ return _snapshot(db_path, project)
224
+
225
+ with gr.Blocks(title="DriftCast Stats") as demo:
226
+ # Tier-1 conversion surface: a single static line pointing at the paid layer
227
+ # (waste attribution, outcome labels, collapse prediction run server-side).
228
+ gr.Markdown("advanced analysis available at driftcast.dev")
229
+ headline = gr.Markdown()
230
+
231
+ with gr.Tab("Coordination"):
232
+ gr.Markdown(
233
+ "<sub>One top-level tree per agent (project), each accumulating its "
234
+ "runs newest-first. Within a run: orchestrator → chosen subagent → "
235
+ "provider calls. The badge explains why each run routed where it did; "
236
+ "the routing LLM calls (route_classify / judge_answer / synthesize) "
237
+ "show the tokens and cost of the coordination itself.</sub>"
238
+ )
239
+ coordination = gr.Markdown()
240
+
241
+ with gr.Tab("By model"):
242
+ model_df = gr.Dataframe(
243
+ headers=["Model", "Calls", "In tok", "Out tok", "Cost ($)", "Avg ms"],
244
+ datatype=["str", "number", "str", "str", "str", "str"],
245
+ interactive=False,
246
+ wrap=True,
247
+ )
248
+
249
+ outputs = [headline, coordination, model_df]
250
+ demo.load(refresh, outputs=outputs) # initial paint
251
+ gr.Timer(refresh_seconds).tick(refresh, outputs=outputs) # live refresh
252
+
253
+ return demo
254
+
255
+
256
+ def launch(
257
+ db_path: str = "./driftcast.db",
258
+ project: str | None = None,
259
+ port: int = 7861,
260
+ refresh_seconds: float = 2.0,
261
+ block: bool = True,
262
+ **launch_kwargs,
263
+ ):
264
+ """Single launch entry point offering both a blocking CLI mode and a non-blocking mode so the dashboard can pop up beside an agent's own UI."""
265
+ demo = build_app(db_path, project, refresh_seconds)
266
+ demo.launch(
267
+ server_port=port,
268
+ prevent_thread_lock=not block,
269
+ inbrowser=not block,
270
+ show_error=True,
271
+ quiet=True,
272
+ **launch_kwargs,
273
+ )
274
+ return demo
275
+
276
+
277
+ # ── Unified launcher (run this file directly) ─────────────────────────────────
278
+ #
279
+ # Everything above is the generic, reusable SDK dashboard. The block below is
280
+ # this repo's single entry point: `python dashboard.py` opens the unified
281
+ # dashboard (every agent's tree) AND starts the Claude Code OTLP listener in the
282
+ # same process, both pointed at one shared db at the project root. It is the only
283
+ # part of this file tied to the repo layout, so it stays out of the import path
284
+ # and runs only when this file is executed directly.
285
+
286
+
287
+ def _start_claude_code_listener(db_path: str):
288
+ """Best-effort in-process receiver start (lazy import, returns None on failure) so a busy port can never take the dashboard down with it."""
289
+ try:
290
+ from driftcast.otel import claude_code_tel
291
+
292
+ shutdown, _ = claude_code_tel.start_receiver(db_path=db_path)
293
+ print(
294
+ "[dashboard] Claude Code listener -> http://127.0.0.1:4318 "
295
+ "(set OTEL_EXPORTER_OTLP_ENDPOINT to this in the shell running Claude Code)",
296
+ flush=True,
297
+ )
298
+ return shutdown
299
+ except Exception as exc: # a missing receiver or busy port must not kill the dashboard
300
+ print(f"[dashboard] Claude Code listener not started: {exc!r}", flush=True)
301
+ return None
302
+
303
+
304
+ def main():
305
+ """This repo's single entry point: opens the unified dashboard and the Claude Code listener in one process against one shared db."""
306
+ import atexit
307
+ import os
308
+ import threading
309
+
310
+ db_path = default_db_path()
311
+ port = int(os.getenv("DRIFTCAST_DASHBOARD_PORT", "7861"))
312
+
313
+ shutdown = _start_claude_code_listener(db_path)
314
+ if shutdown is not None:
315
+ atexit.register(shutdown)
316
+
317
+ print(f"[dashboard] unified dashboard -> http://127.0.0.1:{port} (db: {db_path})", flush=True)
318
+ # project=None -> all projects: one tree per agent. block=False opens the browser
319
+ # and serves on a background thread; we then hold the main thread so the daemon
320
+ # receiver keeps running and Ctrl+C still flushes buffered sessions via atexit.
321
+ launch(db_path=db_path, project=None, port=port, block=False)
322
+ try:
323
+ threading.Event().wait()
324
+ except KeyboardInterrupt:
325
+ print("\n[dashboard] shutting down...", flush=True)
326
+
327
+
328
+ if __name__ == "__main__":
329
+ main()
@@ -0,0 +1,6 @@
1
+ """driftcast.otel — OpenTelemetry-based ingestion into DriftCast.
2
+
3
+ Home for telemetry experiments that replay an external system's OTel stream into
4
+ the same `runs`/`spans` tables the tracer writes. First resident:
5
+ `claude_code_tel`, a local OTLP/HTTP receiver for Claude Code's telemetry.
6
+ """