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 +17 -0
- driftcast/cli.py +128 -0
- driftcast/dashboard.py +329 -0
- driftcast/otel/__init__.py +6 -0
- driftcast/otel/claude_code_tel.py +591 -0
- driftcast/pricing.py +83 -0
- driftcast/py.typed +0 -0
- driftcast/storage.py +392 -0
- driftcast/tracer.py +460 -0
- driftcast-1.0.0.dist-info/METADATA +163 -0
- driftcast-1.0.0.dist-info/RECORD +15 -0
- driftcast-1.0.0.dist-info/WHEEL +5 -0
- driftcast-1.0.0.dist-info/entry_points.txt +3 -0
- driftcast-1.0.0.dist-info/licenses/LICENSE +21 -0
- driftcast-1.0.0.dist-info/top_level.txt +1 -0
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
|
+
"""
|