Files
synapbus/internal/api/analytics_handler.go
T
Algis DumbrisandClaude Opus 4.7 8a70ba7440 fix(api): route analytics handlers through read pool
The four /api/analytics/* endpoints (timeline, summary, top-agents,
top-channels) all ran their SELECT queries on the write pool
(MaxOpenConns=1, serialized) and would time out at 125s with
context-canceled whenever a long writer (e.g. dream dispatch) held the
single connection. Summary swallows the error and returns {0,0,0}, so
the dashboard rendered an empty-cluster lie.

Plumb ReadDB through RouterConfig from main, fall back to DB if the
read pool is unset, and pass it to NewAnalyticsHandler. Same shape as
the /readyz fix in b03350f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:01:07 +03:00

285 lines
7.8 KiB
Go

package api
import (
"database/sql"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
)
// AnalyticsHandler serves analytics endpoints for the Web UI dashboard.
type AnalyticsHandler struct {
db *sql.DB
agentService *agents.AgentService
channelService *channels.Service
logger *slog.Logger
}
// NewAnalyticsHandler creates a new analytics handler.
// db should be the read pool — every query in this handler is read-only and
// must not block on the serialized write pool.
func NewAnalyticsHandler(db *sql.DB, agentService *agents.AgentService, channelService *channels.Service) *AnalyticsHandler {
return &AnalyticsHandler{
db: db,
agentService: agentService,
channelService: channelService,
logger: slog.Default().With("component", "api.analytics"),
}
}
// parseSpan parses a span parameter and returns the cutoff time and strftime format string.
func parseSpan(span string) (time.Time, string, error) {
now := time.Now().UTC()
switch span {
case "1h":
return now.Add(-1 * time.Hour), "%Y-%m-%d %H:%M", nil
case "4h":
return now.Add(-4 * time.Hour), "%Y-%m-%d %H:%M", nil
case "24h":
return now.Add(-24 * time.Hour), "%Y-%m-%d %H:00", nil
case "7d":
return now.Add(-7 * 24 * time.Hour), "%Y-%m-%d", nil
case "30d":
return now.Add(-30 * 24 * time.Hour), "%Y-%m-%d", nil
default:
return time.Time{}, "", fmt.Errorf("invalid span: %s (valid: 1h, 4h, 24h, 7d, 30d)", span)
}
}
// Timeline handles GET /api/analytics/timeline?span=24h.
func (h *AnalyticsHandler) Timeline(w http.ResponseWriter, r *http.Request) {
span := r.URL.Query().Get("span")
if span == "" {
span = "24h"
}
cutoff, strftimeFmt, err := parseSpan(span)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
return
}
// For 1h and 4h spans, bucket by 5-minute intervals using SQL expression
var query string
if span == "1h" || span == "4h" {
// Round minutes down to nearest 5 using printf
query = `SELECT strftime('%Y-%m-%d %H:', created_at) || printf('%02d', (CAST(strftime('%M', created_at) AS INTEGER) / 5) * 5) AS bucket, COUNT(*) AS count FROM messages WHERE created_at >= ? GROUP BY bucket ORDER BY bucket`
} else {
query = `SELECT strftime(?, created_at) AS bucket, COUNT(*) AS count FROM messages WHERE created_at >= ? GROUP BY bucket ORDER BY bucket`
}
type bucket struct {
Time string `json:"time"`
Count int `json:"count"`
}
var buckets []bucket
var total int
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
var rows *sql.Rows
if span == "1h" || span == "4h" {
rows, err = h.db.QueryContext(r.Context(), query, cutoffStr)
} else {
rows, err = h.db.QueryContext(r.Context(), query, strftimeFmt, cutoffStr)
}
if err != nil {
h.logger.Error("timeline query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query timeline"))
return
}
defer rows.Close()
for rows.Next() {
var b bucket
if err := rows.Scan(&b.Time, &b.Count); err != nil {
h.logger.Error("timeline scan failed", "error", err)
continue
}
buckets = append(buckets, b)
total += b.Count
}
if err := rows.Err(); err != nil {
h.logger.Error("timeline rows error", "error", err)
}
if buckets == nil {
buckets = []bucket{}
}
writeJSON(w, http.StatusOK, map[string]any{
"span": span,
"buckets": buckets,
"total": total,
})
}
// TopAgents handles GET /api/analytics/top-agents?span=24h&limit=5.
func (h *AnalyticsHandler) TopAgents(w http.ResponseWriter, r *http.Request) {
span := r.URL.Query().Get("span")
if span == "" {
span = "24h"
}
cutoff, _, err := parseSpan(span)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
return
}
limit := 5
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
if limit > 100 {
limit = 100
}
}
}
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
rows, err := h.db.QueryContext(r.Context(),
`SELECT from_agent, COUNT(*) as count FROM messages WHERE created_at >= ? AND from_agent != '' GROUP BY from_agent ORDER BY count DESC LIMIT ?`,
cutoffStr, limit,
)
if err != nil {
h.logger.Error("top-agents query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query top agents"))
return
}
defer rows.Close()
type agentStat struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Count int `json:"count"`
}
var agentStats []agentStat
for rows.Next() {
var s agentStat
if err := rows.Scan(&s.Name, &s.Count); err != nil {
h.logger.Error("top-agents scan failed", "error", err)
continue
}
// Look up display name from agent service
if h.agentService != nil {
if agent, err := h.agentService.GetAgent(r.Context(), s.Name); err == nil {
s.DisplayName = agent.DisplayName
}
}
if s.DisplayName == "" {
s.DisplayName = s.Name
}
agentStats = append(agentStats, s)
}
if err := rows.Err(); err != nil {
h.logger.Error("top-agents rows error", "error", err)
}
if agentStats == nil {
agentStats = []agentStat{}
}
writeJSON(w, http.StatusOK, map[string]any{
"span": span,
"agents": agentStats,
})
}
// TopChannels handles GET /api/analytics/top-channels?span=24h&limit=5.
func (h *AnalyticsHandler) TopChannels(w http.ResponseWriter, r *http.Request) {
span := r.URL.Query().Get("span")
if span == "" {
span = "24h"
}
cutoff, _, err := parseSpan(span)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_span", err.Error()))
return
}
limit := 5
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
if limit > 100 {
limit = 100
}
}
}
cutoffStr := cutoff.Format("2006-01-02 15:04:05")
rows, err := h.db.QueryContext(r.Context(),
`SELECT c.name, COUNT(*) as count FROM messages m JOIN channels c ON m.channel_id = c.id WHERE m.created_at >= ? AND m.channel_id IS NOT NULL GROUP BY c.name ORDER BY count DESC LIMIT ?`,
cutoffStr, limit,
)
if err != nil {
h.logger.Error("top-channels query failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to query top channels"))
return
}
defer rows.Close()
type channelStat struct {
Name string `json:"name"`
Count int `json:"count"`
}
var channelStats []channelStat
for rows.Next() {
var s channelStat
if err := rows.Scan(&s.Name, &s.Count); err != nil {
h.logger.Error("top-channels scan failed", "error", err)
continue
}
channelStats = append(channelStats, s)
}
if err := rows.Err(); err != nil {
h.logger.Error("top-channels rows error", "error", err)
}
if channelStats == nil {
channelStats = []channelStat{}
}
writeJSON(w, http.StatusOK, map[string]any{
"span": span,
"channels": channelStats,
})
}
// Summary handles GET /api/analytics/summary.
func (h *AnalyticsHandler) Summary(w http.ResponseWriter, r *http.Request) {
var totalAgents, totalChannels, totalMessages int
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM agents WHERE status = 'active'`).Scan(&totalAgents); err != nil {
h.logger.Error("summary agents count failed", "error", err)
}
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM channels`).Scan(&totalChannels); err != nil {
h.logger.Error("summary channels count failed", "error", err)
}
if err := h.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM messages`).Scan(&totalMessages); err != nil {
h.logger.Error("summary messages count failed", "error", err)
}
writeJSON(w, http.StatusOK, map[string]any{
"total_agents": totalAgents,
"total_channels": totalChannels,
"total_messages": totalMessages,
})
}