Ryanhub - file viewer
filename: agent/telemetry.go
branch: main
back to repo
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
}