package agent
import (
"sort"
"sync"
"time"
)
type ToolTiming struct {
Name string `json:"name"`
Count uint64 `json:"count"`
AvgMs float64 `json:"avg_ms"`
}
type AutomationStatus struct {
Name string `json:"name"`
NextRun string `json:"next_run,omitempty"`
LastRun string `json:"last_run,omitempty"`
LastOK bool `json:"last_ok,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type LogLine struct {
At string `json:"at"`
Message string `json:"message"`
}
type StatusSnapshot struct {
Status string `json:"status"`
CurrentPrompt string `json:"current_prompt,omitempty"`
LastError string `json:"last_error,omitempty"`
RunsCompleted uint64 `json:"runs_completed"`
UptimeSeconds int64 `json:"uptime_seconds"`
LLM struct {
Count uint64 `json:"count"`
AvgMs float64 `json:"avg_ms"`
} `json:"llm"`
Tools struct {
Count uint64 `json:"count"`
AvgMs float64 `json:"avg_ms"`
ByName []ToolTiming `json:"by_name"`
} `json:"tools"`
Automations []AutomationStatus `json:"automations"`
Logs []LogLine `json:"logs"`
}
type toolStats struct {
count uint64
totalMs float64
}
type automationStats struct {
next *time.Time
last *time.Time
lastOK bool
lastErr string
}
type logRing struct {
max int
lines []LogLine
}
func (r *logRing) push(at time.Time, message string) {
if r.max <= 0 {
return
}
line := LogLine{
At: at.Format(time.RFC3339),
Message: message,
}
if len(r.lines) >= r.max {
// drop oldest
r.lines = append(r.lines[:0:0], r.lines[1:]...)
}
r.lines = append(r.lines, line)
}
// Telemetry collects observable agent metrics for the UI.
type Telemetry struct {
mu sync.Mutex
startedAt time.Time
status string
prompt string
lastError string
runsCompleted uint64
llmCount uint64
llmTotalMs float64
toolCount uint64
toolTotalMs float64
toolByName map[string]*toolStats
automations map[string]*automationStats
logs logRing
}
func NewTelemetry(maxLogs int) *Telemetry {
t := &Telemetry{
startedAt: time.Now(),
status: "idle",
toolByName: map[string]*toolStats{},
automations: map[string]*automationStats{},
logs: logRing{max: maxLogs},
}
return t
}
func (t *Telemetry) AddLogLine(message string) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.logs.push(time.Now(), message)
}
func (t *Telemetry) StartRun(prompt string) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.status = "working"
t.prompt = prompt
t.lastError = ""
}
func (t *Telemetry) EndRun(ok bool, errMsg string) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if !ok {
t.lastError = errMsg
}
t.status = "idle"
t.prompt = ""
t.runsCompleted++
}
func (t *Telemetry) ObserveLLM(d time.Duration) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.llmCount++
t.llmTotalMs += float64(d) / float64(time.Millisecond)
}
func (t *Telemetry) ObserveTool(name string, d time.Duration) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.toolCount++
t.toolTotalMs += float64(d) / float64(time.Millisecond)
st := t.toolByName[name]
if st == nil {
st = &toolStats{}
t.toolByName[name] = st
}
st.count++
st.totalMs += float64(d) / float64(time.Millisecond)
}
func (t *Telemetry) SetAutomationNext(name string, next time.Time) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
st := t.automations[name]
if st == nil {
st = &automationStats{}
t.automations[name] = st
}
st.next = &next
}
func (t *Telemetry) SetAutomationLast(name string, last time.Time, ok bool, errMsg string) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
st := t.automations[name]
if st == nil {
st = &automationStats{}
t.automations[name] = st
}
st.last = &last
st.lastOK = ok
st.lastErr = errMsg
}
func (t *Telemetry) Snapshot() StatusSnapshot {
if t == nil {
return StatusSnapshot{}
}
t.mu.Lock()
defer t.mu.Unlock()
var snap StatusSnapshot
snap.Status = t.status
snap.CurrentPrompt = t.prompt
snap.LastError = t.lastError
snap.RunsCompleted = t.runsCompleted
snap.UptimeSeconds = int64(time.Since(t.startedAt).Seconds())
snap.LLM.Count = t.llmCount
if t.llmCount > 0 {
snap.LLM.AvgMs = t.llmTotalMs / float64(t.llmCount)
}
snap.Tools.Count = t.toolCount
if t.toolCount > 0 {
snap.Tools.AvgMs = t.toolTotalMs / float64(t.toolCount)
}
byName := make([]ToolTiming, 0, len(t.toolByName))
for name, st := range t.toolByName {
avg := 0.0
if st.count > 0 {
avg = st.totalMs / float64(st.count)
}
byName = append(byName, ToolTiming{
Name: name,
Count: st.count,
AvgMs: avg,
})
}
sort.Slice(byName, func(i, j int) bool {
return byName[i].AvgMs > byName[j].AvgMs
})
snap.Tools.ByName = byName
autos := make([]AutomationStatus, 0, len(t.automations))
for name, st := range t.automations {
a := AutomationStatus{Name: name}
if st.next != nil {
a.NextRun = st.next.Format(time.RFC3339)
}
if st.last != nil {
a.LastRun = st.last.Format(time.RFC3339)
a.LastOK = st.lastOK
a.LastError = st.lastErr
}
autos = append(autos, a)
}
sort.Slice(autos, func(i, j int) bool { return autos[i].Name < autos[j].Name })
snap.Automations = autos
// Copy logs for JSON stability
snap.Logs = append([]LogLine(nil), t.logs.lines...)
return snap
}