diff --git a/cmd/web-ui/static/modules/pages/dashboard.js b/cmd/web-ui/static/modules/pages/dashboard.js index 54be3ca..2bf32ab 100644 --- a/cmd/web-ui/static/modules/pages/dashboard.js +++ b/cmd/web-ui/static/modules/pages/dashboard.js @@ -488,6 +488,10 @@ function tallyTool(evt) { if (attrs.span_kind === 'tool') { const name = attrs.name || 'unknown'; dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1; + const dur = Number(getEnvelopePayload(evt).duration_ms) || 0; + if (dur > 0) { + dashboardState.toolDurations[name] = (dashboardState.toolDurations[name] || 0) + dur; + } } } } @@ -618,33 +622,34 @@ function renderLatencyPanel() { return; } - const durSeries = ts.series.map(b => b.avg_duration_ms || 0).filter(v => v > 0); - if (durSeries.length === 0) { - container.innerHTML = '

No run latency recorded yet

'; + const latencyBuckets = ts.series.filter(b => (b.tool_avg_ms || 0) > 0); + if (latencyBuckets.length === 0) { + container.innerHTML = '

No tool latency recorded yet

'; return; } + const durSeries = latencyBuckets.map(b => b.tool_avg_ms || 0); const avg = durSeries.reduce((a, b) => a + b, 0) / durSeries.length; const min = Math.min(...durSeries); - const max = Math.max(...durSeries); - const maxBar = max || 1; + const p95 = Math.max(...latencyBuckets.map(b => b.tool_p95_ms || 0)); + const maxBar = Math.max(...durSeries) || 1; container.innerHTML = `
${metricPill({ label: 'Min', value: formatDuration(min), variant: 'range' })} ${metricPill({ label: 'Avg', value: formatDuration(avg), variant: 'range' })} - ${metricPill({ label: 'Max', value: formatDuration(max), variant: 'range' })} + ${metricPill({ label: 'P95', value: formatDuration(p95), variant: 'range' })}
- ${durSeries.map((v, i) => { + ${latencyBuckets.map(b => { + const v = b.tool_avg_ms || 0; const pct = (v / maxBar * 100).toFixed(1); - const label = ts.series.filter(b => b.avg_duration_ms > 0)[i]; - const title = label ? formatBucketLabel(label.ts) + ': ' + formatDuration(v) : formatDuration(v); + const title = formatBucketLabel(b.ts) + ': ' + formatDuration(v); return `
`; }).join('')}
-
Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})
+
Avg tool latency per bucket (${escapeHTML(ts.bucket || '-')})
`; } @@ -749,7 +754,14 @@ function renderDashTopTools() { const topTools = Object.entries(dashboardState.toolCounts) .sort((a, b) => b[1] - a[1]) .slice(0, 10) - .map(([name, count]) => ({ name, count })); + .map(([name, count]) => { + const durSum = dashboardState.toolDurations[name] || 0; + const avg = count > 0 ? durSum / count : 0; + const countDisplay = avg > 0 + ? `${formatCount(count)} ยท ${formatDuration(avg)}` + : formatCount(count); + return { name, count, countDisplay }; + }); list.innerHTML = barRankList(topTools, { emptyText: 'No tool data yet' }); } @@ -776,6 +788,7 @@ export async function renderDashboard(routeToken) { recentEvents: [], recentEventIDs: new Set(), toolCounts: {}, + toolDurations: {}, modelCounts: {}, rightPanelMode: localStorage.getItem('agentmon:dash:right-panel') || 'framework', }; @@ -955,6 +968,7 @@ export async function renderDashboard(routeToken) { for (const t of (topToolsData.tools || [])) { dashboardState.toolCounts[t.name] = t.count; + dashboardState.toolDurations[t.name] = (t.avg_ms || 0) * (t.count || 0); } for (const m of (topModelsData.models || [])) { dashboardState.modelCounts[m.name] = m.count; diff --git a/internal/store/postgres/stats.go b/internal/store/postgres/stats.go index 3474fc6..861c214 100644 --- a/internal/store/postgres/stats.go +++ b/internal/store/postgres/stats.go @@ -33,6 +33,8 @@ type TimeseriesBucket struct { OutputTokens int64 `json:"output_tokens"` Cost float64 `json:"cost"` AvgDurationMS float64 `json:"avg_duration_ms"` + ToolAvgMS float64 `json:"tool_avg_ms"` + ToolP95MS float64 `json:"tool_p95_ms"` } type TimeseriesResult struct { @@ -157,8 +159,11 @@ func (d *DB) GetSummary(ctx context.Context) (*Summary, error) { } type TopTool struct { - Name string `json:"name"` - Count int `json:"count"` + Name string `json:"name"` + Count int `json:"count"` + AvgMS float64 `json:"avg_ms"` + P95MS float64 `json:"p95_ms"` + Errors int `json:"errors"` } type TopModel struct { @@ -176,7 +181,11 @@ func (d *DB) GetTopTools(ctx context.Context, limit int) ([]TopTool, error) { q := ` SELECT payload->'attributes'->>'name' AS tool_name, - COUNT(*) AS cnt + COUNT(*) AS cnt, + COALESCE(AVG((payload->'payload'->>'duration_ms')::float8), 0) AS avg_ms, + COALESCE(percentile_cont(0.95) WITHIN GROUP ( + ORDER BY (payload->'payload'->>'duration_ms')::float8), 0) AS p95_ms, + COUNT(*) FILTER (WHERE payload->'payload'->>'status' = 'error') AS errors FROM events WHERE type = 'span.end' AND payload->'attributes'->>'span_kind' = 'tool' @@ -195,7 +204,7 @@ func (d *DB) GetTopTools(ctx context.Context, limit int) ([]TopTool, error) { var out []TopTool for rows.Next() { var t TopTool - if err := rows.Scan(&t.Name, &t.Count); err != nil { + if err := rows.Scan(&t.Name, &t.Count, &t.AvgMS, &t.P95MS, &t.Errors); err != nil { return nil, err } out = append(out, t) @@ -300,7 +309,14 @@ func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResul COALESCE(SUM((payload->'payload'->'usage'->>'total_cost')::float8) FILTER (WHERE type = 'run.end'), 0) AS cost, COALESCE(AVG((payload->'payload'->>'duration_ms')::float8) - FILTER (WHERE type = 'run.end'), 0) AS avg_duration_ms + FILTER (WHERE type = 'run.end'), 0) AS avg_duration_ms, + COALESCE(AVG((payload->'payload'->>'duration_ms')::float8) + FILTER (WHERE type = 'span.end' + AND payload->'attributes'->>'span_kind' = 'tool'), 0) AS tool_avg_ms, + COALESCE(percentile_cont(0.95) WITHIN GROUP ( + ORDER BY (payload->'payload'->>'duration_ms')::float8) + FILTER (WHERE type = 'span.end' + AND payload->'attributes'->>'span_kind' = 'tool'), 0) AS tool_p95_ms FROM events WHERE ts >= $2 AND type IN ('run.start', 'run.end', 'span.end', 'error') @@ -318,7 +334,8 @@ func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResul for rows.Next() { var b TimeseriesBucket if err := rows.Scan(&b.TS, &b.Runs, &b.Tools, &b.Errors, - &b.Tokens, &b.InputTokens, &b.OutputTokens, &b.Cost, &b.AvgDurationMS); err != nil { + &b.Tokens, &b.InputTokens, &b.OutputTokens, &b.Cost, &b.AvgDurationMS, + &b.ToolAvgMS, &b.ToolP95MS); err != nil { return nil, err } series = append(series, b)