Skip to content

Commit d8b0ba7

Browse files
Siddhant-K-codeOnaona-agent
authored
feat: v0.17.0 — multi-session dashboard with trend charts (#33)
* feat: multi-session dashboard with trend charts Adds dashboard.py with build_dashboard() that aggregates cost, duration, tool calls, errors, and success rate across sessions. Outputs a terminal table with a sparkline cost trend, or a self-contained HTML page with stat cards and an SVG trend line. CLI: agent-strace dashboard [--limit N] [--agent filter] [--output file.html] Closes #23 Co-authored-by: Ona <no-reply@ona.com> * fix: avoid ZeroDivisionError in HTML sparkline when all costs are zero max_c used 'if costs else 1.0' which still divides by zero when all costs are 0.0. Use 'or 1.0' instead. Also remove unused EventType import. Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <ona@gitpod.io> Co-authored-by: Ona <no-reply@ona.com>
1 parent f24004f commit d8b0ba7

2 files changed

Lines changed: 398 additions & 0 deletions

File tree

src/agent_trace/dashboard.py

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
"""Multi-session dashboard: aggregate view across sessions with trend data.
2+
3+
Produces a terminal table and an optional self-contained HTML dashboard
4+
showing cost, duration, tool calls, errors, and trend lines across all
5+
(or a filtered set of) sessions.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import html
12+
import json
13+
import sys
14+
from dataclasses import dataclass, field
15+
from datetime import datetime, timezone
16+
from typing import TextIO
17+
18+
from .models import SessionMeta
19+
from .store import TraceStore
20+
21+
22+
# ---------------------------------------------------------------------------
23+
# Data structures
24+
# ---------------------------------------------------------------------------
25+
26+
@dataclass
27+
class SessionSummary:
28+
session_id: str
29+
started_at: float
30+
duration_s: float
31+
tool_calls: int
32+
llm_requests: int
33+
errors: int
34+
total_tokens: int
35+
estimated_cost: float
36+
agent_name: str
37+
succeeded: bool # True if no errors recorded
38+
39+
40+
@dataclass
41+
class DashboardReport:
42+
summaries: list[SessionSummary]
43+
total_cost: float
44+
total_tokens: int
45+
total_tool_calls: int
46+
total_errors: int
47+
avg_duration_s: float
48+
success_rate: float # 0.0–1.0
49+
50+
51+
# ---------------------------------------------------------------------------
52+
# Aggregation
53+
# ---------------------------------------------------------------------------
54+
55+
def build_dashboard(
56+
store: TraceStore,
57+
limit: int = 50,
58+
agent_filter: str = "",
59+
) -> DashboardReport:
60+
"""Build a DashboardReport from the most recent *limit* sessions."""
61+
all_meta = store.list_sessions()
62+
63+
if agent_filter:
64+
all_meta = [m for m in all_meta if agent_filter.lower() in m.agent_name.lower()]
65+
66+
sessions = all_meta[:limit]
67+
68+
summaries: list[SessionSummary] = []
69+
for meta in sessions:
70+
duration_s = meta.total_duration_ms / 1000 if meta.total_duration_ms else 0.0
71+
# Estimate cost cheaply from token count stored in meta
72+
cost = meta.total_tokens / 1_000_000 * 3.0 # rough sonnet input price
73+
74+
summaries.append(SessionSummary(
75+
session_id=meta.session_id,
76+
started_at=meta.started_at,
77+
duration_s=duration_s,
78+
tool_calls=meta.tool_calls,
79+
llm_requests=meta.llm_requests,
80+
errors=meta.errors,
81+
total_tokens=meta.total_tokens,
82+
estimated_cost=cost,
83+
agent_name=meta.agent_name or "unknown",
84+
succeeded=meta.errors == 0,
85+
))
86+
87+
total_cost = sum(s.estimated_cost for s in summaries)
88+
total_tokens = sum(s.total_tokens for s in summaries)
89+
total_tools = sum(s.tool_calls for s in summaries)
90+
total_errors = sum(s.errors for s in summaries)
91+
avg_dur = (
92+
sum(s.duration_s for s in summaries) / len(summaries)
93+
if summaries else 0.0
94+
)
95+
success_rate = (
96+
sum(1 for s in summaries if s.succeeded) / len(summaries)
97+
if summaries else 0.0
98+
)
99+
100+
return DashboardReport(
101+
summaries=summaries,
102+
total_cost=total_cost,
103+
total_tokens=total_tokens,
104+
total_tool_calls=total_tools,
105+
total_errors=total_errors,
106+
avg_duration_s=avg_dur,
107+
success_rate=success_rate,
108+
)
109+
110+
111+
# ---------------------------------------------------------------------------
112+
# Terminal formatting
113+
# ---------------------------------------------------------------------------
114+
115+
def _fmt_dur(s: float) -> str:
116+
if s < 60:
117+
return f"{s:.0f}s"
118+
return f"{int(s)//60}m{int(s)%60:02d}s"
119+
120+
121+
def _fmt_ts(ts: float) -> str:
122+
try:
123+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
124+
return dt.strftime("%m-%d %H:%M")
125+
except Exception:
126+
return "?"
127+
128+
129+
def format_dashboard(report: DashboardReport, out: TextIO = sys.stdout) -> None:
130+
w = out.write
131+
n = len(report.summaries)
132+
133+
w(f"\nDashboard — {n} session{'s' if n != 1 else ''}\n\n")
134+
135+
# Summary row
136+
w(f" Total cost: ~${report.total_cost:.4f}\n")
137+
w(f" Total tokens: {report.total_tokens:,}\n")
138+
w(f" Tool calls: {report.total_tool_calls:,}\n")
139+
w(f" Errors: {report.total_errors}\n")
140+
w(f" Avg duration: {_fmt_dur(report.avg_duration_s)}\n")
141+
w(f" Success rate: {report.success_rate*100:.0f}%\n\n")
142+
143+
if not report.summaries:
144+
return
145+
146+
# Table header
147+
w(f" {'ID':<14} {'Started':<12} {'Dur':>7} {'Tools':>5} "
148+
f"{'LLM':>4} {'Err':>3} {'Tokens':>8} {'Cost':>8} Status\n")
149+
w(" " + "-" * 80 + "\n")
150+
151+
for s in report.summaries:
152+
status = "✓" if s.succeeded else "✗"
153+
w(
154+
f" {s.session_id[:12]:<14} {_fmt_ts(s.started_at):<12} "
155+
f"{_fmt_dur(s.duration_s):>7} {s.tool_calls:>5} "
156+
f"{s.llm_requests:>4} {s.errors:>3} "
157+
f"{s.total_tokens:>8,} ${s.estimated_cost:>7.4f} {status}\n"
158+
)
159+
160+
w("\n")
161+
162+
# Trend: last 10 sessions cost
163+
if len(report.summaries) >= 3:
164+
recent = list(reversed(report.summaries[:10]))
165+
costs = [s.estimated_cost for s in recent]
166+
max_cost = max(costs) or 1.0
167+
w(" Cost trend (oldest → newest):\n ")
168+
bars = "▁▂▃▄▅▆▇█"
169+
for c in costs:
170+
idx = min(int(c / max_cost * (len(bars) - 1)), len(bars) - 1)
171+
w(bars[idx])
172+
w("\n\n")
173+
174+
175+
# ---------------------------------------------------------------------------
176+
# HTML dashboard
177+
# ---------------------------------------------------------------------------
178+
179+
def render_html_dashboard(report: DashboardReport) -> str:
180+
"""Produce a self-contained HTML dashboard page."""
181+
rows_html = ""
182+
for s in report.summaries:
183+
status_cls = "ok" if s.succeeded else "err"
184+
status_sym = "✓" if s.succeeded else "✗"
185+
rows_html += (
186+
f"<tr class='{status_cls}'>"
187+
f"<td>{html.escape(s.session_id[:12])}</td>"
188+
f"<td>{html.escape(_fmt_ts(s.started_at))}</td>"
189+
f"<td>{html.escape(_fmt_dur(s.duration_s))}</td>"
190+
f"<td>{s.tool_calls}</td>"
191+
f"<td>{s.llm_requests}</td>"
192+
f"<td>{s.errors}</td>"
193+
f"<td>{s.total_tokens:,}</td>"
194+
f"<td>${s.estimated_cost:.4f}</td>"
195+
f"<td>{status_sym}</td>"
196+
f"</tr>\n"
197+
)
198+
199+
# Sparkline data for Chart.js-free inline SVG
200+
costs = [s.estimated_cost for s in reversed(report.summaries[:20])]
201+
max_c = max(costs) or 1.0
202+
spark_points = ""
203+
if costs:
204+
w = 200
205+
h = 40
206+
pts = []
207+
for i, c in enumerate(costs):
208+
x = int(i / max(len(costs) - 1, 1) * w)
209+
y = int(h - (c / max_c) * h)
210+
pts.append(f"{x},{y}")
211+
spark_points = " ".join(pts)
212+
213+
return f"""<!DOCTYPE html>
214+
<html lang="en">
215+
<head>
216+
<meta charset="utf-8">
217+
<title>agent-strace dashboard</title>
218+
<style>
219+
body{{font-family:monospace;background:#0d1117;color:#c9d1d9;margin:0;padding:20px}}
220+
h1{{color:#58a6ff;font-size:1.2em;margin-bottom:16px}}
221+
.stats{{display:flex;gap:24px;margin-bottom:20px;flex-wrap:wrap}}
222+
.stat{{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px 20px}}
223+
.stat .label{{font-size:.75em;color:#8b949e}}
224+
.stat .value{{font-size:1.4em;color:#e6edf3;margin-top:4px}}
225+
table{{width:100%;border-collapse:collapse;font-size:.85em}}
226+
th{{background:#161b22;color:#8b949e;padding:6px 10px;text-align:left;border-bottom:1px solid #30363d}}
227+
td{{padding:5px 10px;border-bottom:1px solid #21262d}}
228+
tr.ok td:last-child{{color:#3fb950}}
229+
tr.err td:last-child{{color:#f85149}}
230+
tr:hover{{background:#161b22}}
231+
.spark{{margin-bottom:20px}}
232+
polyline{{fill:none;stroke:#58a6ff;stroke-width:1.5}}
233+
</style>
234+
</head>
235+
<body>
236+
<h1>agent-strace dashboard</h1>
237+
<div class="stats">
238+
<div class="stat"><div class="label">Sessions</div><div class="value">{len(report.summaries)}</div></div>
239+
<div class="stat"><div class="label">Est. cost</div><div class="value">${report.total_cost:.4f}</div></div>
240+
<div class="stat"><div class="label">Total tokens</div><div class="value">{report.total_tokens:,}</div></div>
241+
<div class="stat"><div class="label">Tool calls</div><div class="value">{report.total_tool_calls:,}</div></div>
242+
<div class="stat"><div class="label">Errors</div><div class="value">{report.total_errors}</div></div>
243+
<div class="stat"><div class="label">Success rate</div><div class="value">{report.success_rate*100:.0f}%</div></div>
244+
<div class="stat"><div class="label">Avg duration</div><div class="value">{_fmt_dur(report.avg_duration_s)}</div></div>
245+
</div>
246+
<div class="spark">
247+
<svg width="200" height="40" viewBox="0 0 200 40">
248+
<polyline points="{spark_points}"/>
249+
</svg>
250+
<span style="font-size:.75em;color:#8b949e"> cost trend</span>
251+
</div>
252+
<table>
253+
<thead><tr>
254+
<th>Session</th><th>Started</th><th>Duration</th>
255+
<th>Tools</th><th>LLM</th><th>Errors</th>
256+
<th>Tokens</th><th>Cost</th><th>Status</th>
257+
</tr></thead>
258+
<tbody>
259+
{rows_html}
260+
</tbody>
261+
</table>
262+
</body>
263+
</html>"""
264+
265+
266+
# ---------------------------------------------------------------------------
267+
# CLI handler
268+
# ---------------------------------------------------------------------------
269+
270+
def cmd_dashboard(args: argparse.Namespace) -> int:
271+
store = TraceStore(args.trace_dir)
272+
limit = getattr(args, "limit", 50) or 50
273+
agent_filter = getattr(args, "agent", "") or ""
274+
275+
report = build_dashboard(store, limit=limit, agent_filter=agent_filter)
276+
277+
output_path = getattr(args, "output", None)
278+
if output_path:
279+
from pathlib import Path
280+
html_content = render_html_dashboard(report)
281+
Path(output_path).write_text(html_content)
282+
sys.stdout.write(f"Dashboard written to {output_path}\n")
283+
return 0
284+
285+
format_dashboard(report)
286+
return 0

tests/test_dashboard.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Tests for multi-session dashboard (issue #23)."""
2+
3+
import io
4+
import os
5+
import sys
6+
import tempfile
7+
import unittest
8+
9+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10+
11+
from agent_trace.dashboard import (
12+
DashboardReport,
13+
SessionSummary,
14+
build_dashboard,
15+
format_dashboard,
16+
render_html_dashboard,
17+
)
18+
from agent_trace.models import SessionMeta
19+
from agent_trace.store import TraceStore
20+
21+
22+
def _make_store_with_sessions(n: int) -> TraceStore:
23+
tmpdir = tempfile.mkdtemp()
24+
store = TraceStore(tmpdir)
25+
for i in range(n):
26+
meta = SessionMeta(
27+
agent_name="test-agent",
28+
tool_calls=i * 3,
29+
llm_requests=i + 1,
30+
errors=1 if i % 3 == 0 else 0,
31+
total_tokens=i * 1000,
32+
total_duration_ms=i * 5000,
33+
)
34+
store.create_session(meta)
35+
return store
36+
37+
38+
class TestBuildDashboard(unittest.TestCase):
39+
def test_empty_store(self):
40+
tmpdir = tempfile.mkdtemp()
41+
store = TraceStore(tmpdir)
42+
report = build_dashboard(store)
43+
self.assertEqual(len(report.summaries), 0)
44+
self.assertEqual(report.total_cost, 0.0)
45+
46+
def test_builds_from_sessions(self):
47+
store = _make_store_with_sessions(5)
48+
report = build_dashboard(store)
49+
self.assertEqual(len(report.summaries), 5)
50+
self.assertGreaterEqual(report.total_tool_calls, 0)
51+
52+
def test_limit_respected(self):
53+
store = _make_store_with_sessions(10)
54+
report = build_dashboard(store, limit=3)
55+
self.assertLessEqual(len(report.summaries), 3)
56+
57+
def test_agent_filter(self):
58+
store = _make_store_with_sessions(5)
59+
report = build_dashboard(store, agent_filter="test-agent")
60+
self.assertEqual(len(report.summaries), 5)
61+
62+
def test_agent_filter_no_match(self):
63+
store = _make_store_with_sessions(5)
64+
report = build_dashboard(store, agent_filter="nonexistent")
65+
self.assertEqual(len(report.summaries), 0)
66+
67+
def test_success_rate(self):
68+
store = _make_store_with_sessions(3)
69+
report = build_dashboard(store)
70+
self.assertGreaterEqual(report.success_rate, 0.0)
71+
self.assertLessEqual(report.success_rate, 1.0)
72+
73+
74+
class TestFormatDashboard(unittest.TestCase):
75+
def test_format_no_crash(self):
76+
store = _make_store_with_sessions(3)
77+
report = build_dashboard(store)
78+
buf = io.StringIO()
79+
format_dashboard(report, out=buf)
80+
output = buf.getvalue()
81+
self.assertIn("Dashboard", output)
82+
self.assertIn("sessions", output)
83+
84+
def test_format_empty(self):
85+
report = DashboardReport(
86+
summaries=[], total_cost=0, total_tokens=0,
87+
total_tool_calls=0, total_errors=0,
88+
avg_duration_s=0, success_rate=0,
89+
)
90+
buf = io.StringIO()
91+
format_dashboard(report, out=buf)
92+
self.assertIn("0 session", buf.getvalue())
93+
94+
95+
class TestRenderHtmlDashboard(unittest.TestCase):
96+
def test_renders_html(self):
97+
store = _make_store_with_sessions(3)
98+
report = build_dashboard(store)
99+
html = render_html_dashboard(report)
100+
self.assertIn("<!DOCTYPE html>", html)
101+
self.assertIn("dashboard", html)
102+
self.assertIn("Sessions", html)
103+
104+
def test_html_contains_session_rows(self):
105+
store = _make_store_with_sessions(2)
106+
report = build_dashboard(store)
107+
html = render_html_dashboard(report)
108+
self.assertIn("<tr", html)
109+
110+
111+
if __name__ == "__main__":
112+
unittest.main()

0 commit comments

Comments
 (0)