229 lines
6.8 KiB
Go
229 lines
6.8 KiB
Go
package telemetry
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AgentRunStats is one agent's runs over the window.
|
|
type AgentRunStats struct {
|
|
Agentid string `json:"agentid"`
|
|
Runs int64 `json:"runs"`
|
|
Failed int64 `json:"failed"`
|
|
Avgdurationms float64 `json:"avgdurationms"`
|
|
Lastrunat *time.Time `json:"lastrunat"`
|
|
}
|
|
|
|
// DecisionCount is decisions of one type with one outcome over the window.
|
|
// Outcome is "pending" while none has been recorded.
|
|
type DecisionCount struct {
|
|
Decisiontype string `json:"decisiontype"`
|
|
Outcome string `json:"outcome"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
// DecisionTypeStats rolls DecisionCount rows up per type.
|
|
type DecisionTypeStats struct {
|
|
Decisiontype string `json:"decisiontype"`
|
|
Total int64 `json:"total"`
|
|
Outcomes map[string]int64 `json:"outcomes"`
|
|
}
|
|
|
|
// Insights is everything the Insights tab shows.
|
|
type Insights struct {
|
|
Days int `json:"days"`
|
|
Since time.Time `json:"since"`
|
|
// Receiving is whether this backend is subscribed to AI_engine telemetry.
|
|
// False means an empty run list is "not connected", not "no activity".
|
|
Receiving bool `json:"receiving"`
|
|
Runs RunsSummary `json:"runs"`
|
|
Decisions DecisionSummary `json:"decisions"`
|
|
Live []AgentState `json:"live"`
|
|
}
|
|
|
|
type RunsSummary struct {
|
|
Total int64 `json:"total"`
|
|
Failed int64 `json:"failed"`
|
|
PerAgent []AgentRunStats `json:"peragent"`
|
|
}
|
|
|
|
type DecisionSummary struct {
|
|
Total int64 `json:"total"`
|
|
ByType []DecisionTypeStats `json:"bytype"`
|
|
}
|
|
|
|
// ClampDays keeps the window to what the table retains.
|
|
func ClampDays(days int) int {
|
|
switch {
|
|
case days < 1:
|
|
return 7
|
|
case days > RetentionDays:
|
|
return RetentionDays
|
|
default:
|
|
return days
|
|
}
|
|
}
|
|
|
|
// SummariseRuns totals per-agent rows, busiest agent first.
|
|
func SummariseRuns(rows []AgentRunStats) RunsSummary {
|
|
out := RunsSummary{PerAgent: append([]AgentRunStats{}, rows...)}
|
|
for _, r := range rows {
|
|
out.Total += r.Runs
|
|
out.Failed += r.Failed
|
|
}
|
|
sort.SliceStable(out.PerAgent, func(i, j int) bool {
|
|
if out.PerAgent[i].Runs != out.PerAgent[j].Runs {
|
|
return out.PerAgent[i].Runs > out.PerAgent[j].Runs
|
|
}
|
|
return out.PerAgent[i].Agentid < out.PerAgent[j].Agentid
|
|
})
|
|
return out
|
|
}
|
|
|
|
// SummariseDecisions rolls (type, outcome) counts up per type, largest first.
|
|
func SummariseDecisions(rows []DecisionCount) DecisionSummary {
|
|
byType := map[string]*DecisionTypeStats{}
|
|
var order []string
|
|
var total int64
|
|
for _, r := range rows {
|
|
st, ok := byType[r.Decisiontype]
|
|
if !ok {
|
|
st = &DecisionTypeStats{Decisiontype: r.Decisiontype, Outcomes: map[string]int64{}}
|
|
byType[r.Decisiontype] = st
|
|
order = append(order, r.Decisiontype)
|
|
}
|
|
st.Total += r.Count
|
|
st.Outcomes[r.Outcome] += r.Count
|
|
total += r.Count
|
|
}
|
|
out := DecisionSummary{Total: total, ByType: make([]DecisionTypeStats, 0, len(order))}
|
|
for _, k := range order {
|
|
out.ByType = append(out.ByType, *byType[k])
|
|
}
|
|
sort.SliceStable(out.ByType, func(i, j int) bool {
|
|
if out.ByType[i].Total != out.ByType[j].Total {
|
|
return out.ByType[i].Total > out.ByType[j].Total
|
|
}
|
|
return out.ByType[i].Decisiontype < out.ByType[j].Decisiontype
|
|
})
|
|
return out
|
|
}
|
|
|
|
// RunStats reads per-agent run statistics since the cutoff. A fixed single
|
|
// grouped query, whatever the volume.
|
|
func RunStats(db *gorm.DB, since time.Time) ([]AgentRunStats, error) {
|
|
var rows []AgentRunStats
|
|
err := db.Table("aiagentruns").
|
|
Select(`agentid,
|
|
COUNT(*) AS runs,
|
|
COUNT(*) FILTER (WHERE status <> 'completed') AS failed,
|
|
COALESCE(AVG(durationms), 0) AS avgdurationms,
|
|
MAX(receivedat) AS lastrunat`).
|
|
Where("receivedat >= ?", since).
|
|
Group("agentid").
|
|
Scan(&rows).Error
|
|
return rows, err
|
|
}
|
|
|
|
// DecisionCounts reads agent_decisions grouped by type and outcome. The table
|
|
// is written by the decision engine through POST /internal/agent-decisions.
|
|
func DecisionCounts(db *gorm.DB, since time.Time) ([]DecisionCount, error) {
|
|
var rows []DecisionCount
|
|
err := db.Table("agent_decisions").
|
|
Select("decision_type AS decisiontype, COALESCE(outcome, 'pending') AS outcome, COUNT(*) AS count").
|
|
Where("created_at >= ?", since).
|
|
Group("decision_type, COALESCE(outcome, 'pending')").
|
|
Scan(&rows).Error
|
|
return rows, err
|
|
}
|
|
|
|
// LiveStates reads the latest heartbeat of each agent that has one. Missing
|
|
// keys (an agent silent for over five minutes) are simply absent.
|
|
func LiveStates(rdb *redis.Client, agentIDs []string) []AgentState {
|
|
out := []AgentState{}
|
|
if rdb == nil || len(agentIDs) == 0 {
|
|
return out
|
|
}
|
|
keys := make([]string, len(agentIDs))
|
|
for i, id := range agentIDs {
|
|
keys[i] = StateKey(id)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
vals, err := rdb.MGet(ctx, keys...).Result()
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, v := range vals {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
var st AgentState
|
|
if json.Unmarshal([]byte(s), &st) == nil && st.AgentID != "" {
|
|
out = append(out, st)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DecisionRow is one recent decision, as the Insights list shows it.
|
|
type DecisionRow struct {
|
|
ID uint64 `json:"id"`
|
|
Decisiontype string `json:"decisiontype"`
|
|
Bookingid *uint64 `json:"bookingid"`
|
|
Decision json.RawMessage `json:"decision"`
|
|
Reasoning string `json:"reasoning"`
|
|
Outcome *string `json:"outcome"`
|
|
Createdat time.Time `json:"createdat"`
|
|
}
|
|
|
|
type decisionScan struct {
|
|
ID uint64
|
|
Decisiontype string
|
|
Bookingid *uint64
|
|
Decision string
|
|
Reasoning string
|
|
Outcome *string
|
|
Createdat time.Time
|
|
}
|
|
|
|
// RecentDecisions pages agent_decisions newest first. beforeID (0 = start)
|
|
// is a keyset cursor, so a page is stable while new rows arrive. The context
|
|
// column is deliberately not returned: it can be large and holds rider data.
|
|
func RecentDecisions(db *gorm.DB, decisionType string, beforeID uint64, limit int) ([]DecisionRow, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 25
|
|
}
|
|
q := db.Table("agent_decisions").
|
|
Select("id, decision_type AS decisiontype, booking_id AS bookingid, COALESCE(decision::text, 'null') AS decision, reasoning, outcome, created_at AS createdat").
|
|
Order("id DESC").Limit(limit)
|
|
if decisionType != "" {
|
|
q = q.Where("decision_type = ?", decisionType)
|
|
}
|
|
if beforeID > 0 {
|
|
q = q.Where("id < ?", beforeID)
|
|
}
|
|
var rows []decisionScan
|
|
if err := q.Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]DecisionRow, 0, len(rows))
|
|
for _, r := range rows {
|
|
raw := json.RawMessage(r.Decision)
|
|
if !json.Valid(raw) {
|
|
raw = json.RawMessage("null")
|
|
}
|
|
out = append(out, DecisionRow{
|
|
ID: r.ID, Decisiontype: r.Decisiontype, Bookingid: r.Bookingid, Decision: raw,
|
|
Reasoning: truncate(r.Reasoning, 1000), Outcome: r.Outcome, Createdat: r.Createdat,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|