Add ListSessionsSeq, EventsSeq, SearchSeq iterators for streaming. Use slices.SortFunc, slices.Sorted(maps.Keys()), slices.Collect in ListSessions, Search, FormatAnalytics, extractToolInput. Co-Authored-By: Gemini <noreply@google.com> Co-Authored-By: Virgil <virgil@lethean.io>
132 lines
3.5 KiB
Go
132 lines
3.5 KiB
Go
// SPDX-Licence-Identifier: EUPL-1.2
|
|
package session
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SessionAnalytics holds computed metrics for a parsed session.
|
|
type SessionAnalytics struct {
|
|
Duration time.Duration
|
|
ActiveTime time.Duration
|
|
EventCount int
|
|
ToolCounts map[string]int
|
|
ErrorCounts map[string]int
|
|
SuccessRate float64
|
|
AvgLatency map[string]time.Duration
|
|
MaxLatency map[string]time.Duration
|
|
EstimatedInputTokens int
|
|
EstimatedOutputTokens int
|
|
}
|
|
|
|
// Analyse iterates session events and computes analytics. Pure function, no I/O.
|
|
func Analyse(sess *Session) *SessionAnalytics {
|
|
a := &SessionAnalytics{
|
|
ToolCounts: make(map[string]int),
|
|
ErrorCounts: make(map[string]int),
|
|
AvgLatency: make(map[string]time.Duration),
|
|
MaxLatency: make(map[string]time.Duration),
|
|
}
|
|
|
|
if sess == nil {
|
|
return a
|
|
}
|
|
|
|
a.Duration = sess.EndTime.Sub(sess.StartTime)
|
|
a.EventCount = len(sess.Events)
|
|
|
|
// Track totals for latency averaging
|
|
type latencyAccum struct {
|
|
total time.Duration
|
|
count int
|
|
}
|
|
latencies := make(map[string]*latencyAccum)
|
|
|
|
var totalToolCalls int
|
|
var totalErrors int
|
|
|
|
for _, evt := range sess.Events {
|
|
// Token estimation: ~4 chars per token
|
|
a.EstimatedInputTokens += len(evt.Input) / 4
|
|
a.EstimatedOutputTokens += len(evt.Output) / 4
|
|
|
|
if evt.Type != "tool_use" {
|
|
continue
|
|
}
|
|
|
|
totalToolCalls++
|
|
a.ToolCounts[evt.Tool]++
|
|
|
|
if !evt.Success {
|
|
totalErrors++
|
|
a.ErrorCounts[evt.Tool]++
|
|
}
|
|
|
|
// Active time: sum of tool call durations
|
|
a.ActiveTime += evt.Duration
|
|
|
|
// Latency tracking
|
|
if _, ok := latencies[evt.Tool]; !ok {
|
|
latencies[evt.Tool] = &latencyAccum{}
|
|
}
|
|
latencies[evt.Tool].total += evt.Duration
|
|
latencies[evt.Tool].count++
|
|
|
|
if evt.Duration > a.MaxLatency[evt.Tool] {
|
|
a.MaxLatency[evt.Tool] = evt.Duration
|
|
}
|
|
}
|
|
|
|
// Compute averages
|
|
for tool, acc := range latencies {
|
|
if acc.count > 0 {
|
|
a.AvgLatency[tool] = acc.total / time.Duration(acc.count)
|
|
}
|
|
}
|
|
|
|
// Success rate
|
|
if totalToolCalls > 0 {
|
|
a.SuccessRate = float64(totalToolCalls-totalErrors) / float64(totalToolCalls)
|
|
}
|
|
|
|
return a
|
|
}
|
|
|
|
// FormatAnalytics returns a tabular text summary suitable for CLI display.
|
|
func FormatAnalytics(a *SessionAnalytics) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString("Session Analytics\n")
|
|
b.WriteString(strings.Repeat("=", 50) + "\n\n")
|
|
|
|
b.WriteString(fmt.Sprintf(" Duration: %s\n", formatDuration(a.Duration)))
|
|
b.WriteString(fmt.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime)))
|
|
b.WriteString(fmt.Sprintf(" Events: %d\n", a.EventCount))
|
|
b.WriteString(fmt.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100))
|
|
b.WriteString(fmt.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens))
|
|
b.WriteString(fmt.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens))
|
|
|
|
if len(a.ToolCounts) > 0 {
|
|
b.WriteString("\n Tool Breakdown\n")
|
|
b.WriteString(" " + strings.Repeat("-", 48) + "\n")
|
|
b.WriteString(fmt.Sprintf(" %-14s %6s %6s %10s %10s\n",
|
|
"Tool", "Calls", "Errors", "Avg", "Max"))
|
|
b.WriteString(" " + strings.Repeat("-", 48) + "\n")
|
|
|
|
// Sort tools for deterministic output
|
|
for _, tool := range slices.Sorted(maps.Keys(a.ToolCounts)) {
|
|
errors := a.ErrorCounts[tool]
|
|
avg := a.AvgLatency[tool]
|
|
max := a.MaxLatency[tool]
|
|
b.WriteString(fmt.Sprintf(" %-14s %6d %6d %10s %10s\n",
|
|
tool, a.ToolCounts[tool], errors,
|
|
formatDuration(avg), formatDuration(max)))
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|