mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 00:14:33 +00:00
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
625 lines
17 KiB
Go
625 lines
17 KiB
Go
package tracing
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// SnapshotWorker periodically aggregates trace/span data into usage_snapshots.
|
|
type SnapshotWorker struct {
|
|
db *sql.DB
|
|
snapshots store.SnapshotStore
|
|
usageEvents store.UsageEventStore
|
|
stopCh chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
const recentUsageRefreshWindow = 2 * time.Hour
|
|
|
|
func NewSnapshotWorker(db *sql.DB, snapshots store.SnapshotStore, usageEvents store.UsageEventStore) *SnapshotWorker {
|
|
return &SnapshotWorker{
|
|
db: db,
|
|
snapshots: snapshots,
|
|
usageEvents: usageEvents,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start launches the background aggregation loop.
|
|
func (w *SnapshotWorker) Start() {
|
|
w.wg.Add(1)
|
|
go w.loop()
|
|
slog.Info("snapshot worker started")
|
|
}
|
|
|
|
// Stop signals the worker to stop and waits for completion.
|
|
func (w *SnapshotWorker) Stop() {
|
|
close(w.stopCh)
|
|
w.wg.Wait()
|
|
slog.Info("snapshot worker stopped")
|
|
}
|
|
|
|
func (w *SnapshotWorker) loop() {
|
|
defer w.wg.Done()
|
|
|
|
// On startup, catch up any missed hours
|
|
w.catchUp()
|
|
|
|
// Tick at HH:05:00 UTC (5 min past the hour)
|
|
now := time.Now().UTC()
|
|
nextTick := now.Truncate(time.Hour).Add(time.Hour).Add(5 * time.Minute)
|
|
if now.After(nextTick) {
|
|
nextTick = nextTick.Add(time.Hour)
|
|
}
|
|
timer := time.NewTimer(time.Until(nextTick))
|
|
defer timer.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-w.stopCh:
|
|
return
|
|
case <-timer.C:
|
|
w.catchUp()
|
|
// Reset for next hour
|
|
nextTick = nextTick.Add(time.Hour)
|
|
timer.Reset(time.Until(nextTick))
|
|
}
|
|
}
|
|
}
|
|
|
|
// catchUp computes missing buckets and refreshes recent closed buckets that may have late writes.
|
|
func (w *SnapshotWorker) catchUp() {
|
|
ctx := context.Background()
|
|
w.catchUpSnapshots(ctx)
|
|
w.catchUpUsageEvents(ctx)
|
|
}
|
|
|
|
func (w *SnapshotWorker) catchUpSnapshots(ctx context.Context) {
|
|
now := time.Now().UTC()
|
|
targetHour := now.Truncate(time.Hour).Add(-time.Hour) // previous complete hour
|
|
|
|
latest, err := w.snapshots.GetLatestBucket(ctx)
|
|
if err != nil {
|
|
slog.Warn("snapshot: get latest bucket", "error", err)
|
|
return
|
|
}
|
|
|
|
startHour := usageCatchUpStartHour(latest, targetHour)
|
|
|
|
for h := startHour; !h.After(targetHour); h = h.Add(time.Hour) {
|
|
start := time.Now()
|
|
if err := w.aggregateHour(ctx, h); err != nil {
|
|
slog.Warn("snapshot: aggregate hour failed", "hour", h.Format(time.RFC3339), "error", err)
|
|
return // stop catch-up on error, will retry next tick
|
|
}
|
|
slog.Info("snapshot computed", "hour", h.Format(time.RFC3339), "duration_ms", time.Since(start).Milliseconds())
|
|
}
|
|
}
|
|
|
|
func (w *SnapshotWorker) catchUpUsageEvents(ctx context.Context) {
|
|
if w.usageEvents == nil {
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
targetHour := now.Truncate(time.Hour).Add(-time.Hour)
|
|
|
|
latest, err := w.usageEvents.GetLatestEventRollupBucket(ctx)
|
|
if err != nil {
|
|
slog.Warn("usage_event_rollup: get latest bucket", "error", err)
|
|
return
|
|
}
|
|
|
|
startHour := usageCatchUpStartHour(latest, targetHour)
|
|
|
|
for h := startHour; !h.After(targetHour); h = h.Add(time.Hour) {
|
|
start := time.Now()
|
|
if err := w.usageEvents.RefreshEventRollupHour(ctx, h); err != nil {
|
|
slog.Warn("usage_event_rollup: aggregate hour failed", "hour", h.Format(time.RFC3339), "error", err)
|
|
return
|
|
}
|
|
slog.Info("usage event rollup computed", "hour", h.Format(time.RFC3339), "duration_ms", time.Since(start).Milliseconds())
|
|
}
|
|
}
|
|
|
|
func usageCatchUpStartHour(latest *time.Time, targetHour time.Time) time.Time {
|
|
targetHour = targetHour.UTC().Truncate(time.Hour)
|
|
if latest == nil {
|
|
// Historical backfill handles older data; steady-state startup should refresh the last closed bucket.
|
|
return targetHour
|
|
}
|
|
|
|
nextMissing := latest.UTC().Truncate(time.Hour).Add(time.Hour)
|
|
refreshStart := targetHour.Add(-recentUsageRefreshWindow)
|
|
if nextMissing.Before(refreshStart) {
|
|
return nextMissing
|
|
}
|
|
return refreshStart
|
|
}
|
|
|
|
// Backfill populates usage_snapshots from historical trace/span data.
|
|
// Returns the number of hours processed.
|
|
func (w *SnapshotWorker) Backfill(ctx context.Context) (int, error) {
|
|
latest, err := w.snapshots.GetLatestBucket(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get latest bucket: %w", err)
|
|
}
|
|
|
|
// Find earliest root trace
|
|
var earliest sql.NullTime
|
|
err = w.db.QueryRowContext(ctx,
|
|
`SELECT MIN(start_time) FROM traces WHERE parent_trace_id IS NULL`,
|
|
).Scan(&earliest)
|
|
if err != nil || !earliest.Valid {
|
|
return 0, nil // no traces to backfill
|
|
}
|
|
|
|
startHour := earliest.Time.UTC().Truncate(time.Hour)
|
|
if latest != nil {
|
|
startHour = latest.Add(time.Hour)
|
|
}
|
|
|
|
endHour := time.Now().UTC().Truncate(time.Hour)
|
|
count := 0
|
|
for h := startHour; h.Before(endHour); h = h.Add(time.Hour) {
|
|
if err := w.aggregateHour(ctx, h); err != nil {
|
|
slog.Warn("backfill: aggregate hour failed", "hour", h.Format(time.RFC3339), "error", err)
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
eventCount, err := w.backfillUsageEvents(ctx)
|
|
if err != nil {
|
|
return count, err
|
|
}
|
|
return count + eventCount, nil
|
|
}
|
|
|
|
func (w *SnapshotWorker) RefreshBuckets(ctx context.Context, buckets []time.Time) (int, error) {
|
|
seen := make(map[time.Time]struct{}, len(buckets))
|
|
count := 0
|
|
for _, bucket := range buckets {
|
|
hour := bucket.UTC().Truncate(time.Hour)
|
|
if _, ok := seen[hour]; ok {
|
|
continue
|
|
}
|
|
seen[hour] = struct{}{}
|
|
if err := w.aggregateHour(ctx, hour); err != nil {
|
|
return count, fmt.Errorf("refresh snapshot bucket %s: %w", hour.Format(time.RFC3339), err)
|
|
}
|
|
count++
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (w *SnapshotWorker) backfillUsageEvents(ctx context.Context) (int, error) {
|
|
if w.usageEvents == nil {
|
|
return 0, nil
|
|
}
|
|
latest, err := w.usageEvents.GetLatestEventRollupBucket(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get latest event rollup bucket: %w", err)
|
|
}
|
|
|
|
var earliest sql.NullTime
|
|
if err := w.db.QueryRowContext(ctx, `SELECT MIN(event_time) FROM usage_events`).Scan(&earliest); err != nil || !earliest.Valid {
|
|
return 0, nil
|
|
}
|
|
|
|
startHour := earliest.Time.UTC().Truncate(time.Hour)
|
|
if latest != nil {
|
|
startHour = latest.Add(time.Hour)
|
|
}
|
|
|
|
endHour := time.Now().UTC().Truncate(time.Hour)
|
|
count := 0
|
|
for h := startHour; h.Before(endHour); h = h.Add(time.Hour) {
|
|
if err := w.usageEvents.RefreshEventRollupHour(ctx, h); err != nil {
|
|
slog.Warn("usage_event_rollup backfill: aggregate hour failed", "hour", h.Format(time.RFC3339), "error", err)
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (w *SnapshotWorker) aggregateHour(ctx context.Context, bucketStart time.Time) error {
|
|
bucketEnd := bucketStart.Add(time.Hour)
|
|
|
|
// Query 1: trace-level metrics by (agent_id, channel)
|
|
traceRows, err := queryTraceAggregates(ctx, w.db, bucketStart, bucketEnd)
|
|
if err != nil {
|
|
return fmt.Errorf("trace aggregates: %w", err)
|
|
}
|
|
|
|
// Query 2: span-level metrics by (agent_id, channel, provider, model)
|
|
spanRows, err := querySpanAggregates(ctx, w.db, bucketStart, bucketEnd)
|
|
if err != nil {
|
|
return fmt.Errorf("span aggregates: %w", err)
|
|
}
|
|
|
|
// Memory & KG point-in-time counts
|
|
memoryCounts, err := queryMemoryCounts(ctx, w.db)
|
|
if err != nil {
|
|
slog.Warn("snapshot: memory counts failed, continuing without", "error", err)
|
|
memoryCounts = nil
|
|
}
|
|
kgCounts, err := queryKGCounts(ctx, w.db)
|
|
if err != nil {
|
|
slog.Warn("snapshot: kg counts failed, continuing without", "error", err)
|
|
kgCounts = nil
|
|
}
|
|
|
|
// Merge into UsageSnapshot rows
|
|
snapshots := mergeTraceAndSpanRows(bucketStart, traceRows, spanRows, memoryCounts, kgCounts)
|
|
|
|
if len(snapshots) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return w.snapshots.UpsertSnapshots(ctx, snapshots)
|
|
}
|
|
|
|
// traceAggregate holds trace-level metrics for one (agent_id, channel) group.
|
|
type traceAggregate struct {
|
|
AgentID *uuid.UUID
|
|
Channel string
|
|
RequestCount int
|
|
ErrorCount int
|
|
UniqueUsers int
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
TotalCost float64
|
|
ToolCallCount int
|
|
AvgDurationMS int
|
|
}
|
|
|
|
func queryTraceAggregates(ctx context.Context, db *sql.DB, from, to time.Time) ([]traceAggregate, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT
|
|
agent_id,
|
|
COALESCE(channel, '') as channel,
|
|
COUNT(*) as request_count,
|
|
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count,
|
|
COUNT(DISTINCT user_id) as unique_users,
|
|
COALESCE(SUM(total_input_tokens), 0) as input_tokens,
|
|
COALESCE(SUM(total_output_tokens), 0) as output_tokens,
|
|
COALESCE(SUM(total_cost), 0) as total_cost,
|
|
COALESCE(SUM(tool_call_count), 0) as tool_call_count,
|
|
CAST(COALESCE(AVG(duration_ms), 0) AS INTEGER) as avg_duration_ms
|
|
FROM traces
|
|
WHERE start_time >= $1 AND start_time < $2
|
|
AND parent_trace_id IS NULL
|
|
GROUP BY agent_id, channel`, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []traceAggregate
|
|
for rows.Next() {
|
|
var ta traceAggregate
|
|
if err := rows.Scan(
|
|
&ta.AgentID, &ta.Channel,
|
|
&ta.RequestCount, &ta.ErrorCount, &ta.UniqueUsers,
|
|
&ta.InputTokens, &ta.OutputTokens, &ta.TotalCost,
|
|
&ta.ToolCallCount, &ta.AvgDurationMS,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, ta)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// spanAggregate holds span-level LLM metrics for one (agent_id, channel, provider, model) group.
|
|
type spanAggregate struct {
|
|
AgentID *uuid.UUID
|
|
Channel string
|
|
Provider string
|
|
Model string
|
|
LLMCallCount int
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
TotalCost float64
|
|
CacheReadTokens int64
|
|
CacheCreateTokens int64
|
|
ThinkingTokens int64
|
|
}
|
|
|
|
func querySpanAggregates(ctx context.Context, db *sql.DB, from, to time.Time) ([]spanAggregate, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT
|
|
t.agent_id,
|
|
COALESCE(t.channel, '') as channel,
|
|
COALESCE(s.provider, '') as provider,
|
|
COALESCE(s.model, '') as model,
|
|
COUNT(*) as llm_call_count,
|
|
COALESCE(SUM(s.input_tokens), 0) as span_input_tokens,
|
|
COALESCE(SUM(s.output_tokens), 0) as span_output_tokens,
|
|
COALESCE(SUM(s.total_cost), 0) as span_cost,
|
|
COALESCE(SUM(CAST(s.metadata->>'cache_read_tokens' AS INTEGER)), 0) as cache_read_tokens,
|
|
COALESCE(SUM(CAST(s.metadata->>'cache_creation_tokens' AS INTEGER)), 0) as cache_create_tokens,
|
|
COALESCE(SUM(CAST(s.metadata->>'thinking_tokens' AS INTEGER)), 0) as thinking_tokens
|
|
FROM traces t
|
|
JOIN spans s ON s.trace_id = t.id AND s.span_type = 'llm_call'
|
|
WHERE t.start_time >= $1 AND t.start_time < $2
|
|
AND t.parent_trace_id IS NULL
|
|
GROUP BY t.agent_id, t.channel, s.provider, s.model`, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []spanAggregate
|
|
for rows.Next() {
|
|
var sa spanAggregate
|
|
if err := rows.Scan(
|
|
&sa.AgentID, &sa.Channel,
|
|
&sa.Provider, &sa.Model,
|
|
&sa.LLMCallCount,
|
|
&sa.InputTokens, &sa.OutputTokens, &sa.TotalCost,
|
|
&sa.CacheReadTokens, &sa.CacheCreateTokens, &sa.ThinkingTokens,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, sa)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// agentMemoryCounts holds point-in-time memory counts for one agent.
|
|
type agentMemoryCounts struct {
|
|
AgentID uuid.UUID
|
|
Docs int
|
|
Chunks int
|
|
}
|
|
|
|
// agentKGCounts holds point-in-time KG counts for one agent.
|
|
type agentKGCounts struct {
|
|
AgentID uuid.UUID
|
|
Entities int
|
|
Relations int
|
|
}
|
|
|
|
func queryMemoryCounts(ctx context.Context, db *sql.DB) (map[uuid.UUID]agentMemoryCounts, error) {
|
|
result := make(map[uuid.UUID]agentMemoryCounts)
|
|
|
|
// Document counts
|
|
rows, err := db.QueryContext(ctx, `SELECT agent_id, COUNT(*) FROM memory_documents GROUP BY agent_id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var agentID uuid.UUID
|
|
var count int
|
|
if err := rows.Scan(&agentID, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
mc := result[agentID]
|
|
mc.AgentID = agentID
|
|
mc.Docs = count
|
|
result[agentID] = mc
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Chunk counts
|
|
rows2, err := db.QueryContext(ctx, `SELECT agent_id, COUNT(*) FROM memory_chunks GROUP BY agent_id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows2.Close()
|
|
for rows2.Next() {
|
|
var agentID uuid.UUID
|
|
var count int
|
|
if err := rows2.Scan(&agentID, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
mc := result[agentID]
|
|
mc.AgentID = agentID
|
|
mc.Chunks = count
|
|
result[agentID] = mc
|
|
}
|
|
return result, rows2.Err()
|
|
}
|
|
|
|
func queryKGCounts(ctx context.Context, db *sql.DB) (map[uuid.UUID]agentKGCounts, error) {
|
|
result := make(map[uuid.UUID]agentKGCounts)
|
|
|
|
rows, err := db.QueryContext(ctx, `SELECT agent_id, COUNT(*) FROM kg_entities GROUP BY agent_id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var agentID uuid.UUID
|
|
var count int
|
|
if err := rows.Scan(&agentID, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
kc := result[agentID]
|
|
kc.AgentID = agentID
|
|
kc.Entities = count
|
|
result[agentID] = kc
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows2, err := db.QueryContext(ctx, `SELECT agent_id, COUNT(*) FROM kg_relations GROUP BY agent_id`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows2.Close()
|
|
for rows2.Next() {
|
|
var agentID uuid.UUID
|
|
var count int
|
|
if err := rows2.Scan(&agentID, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
kc := result[agentID]
|
|
kc.AgentID = agentID
|
|
kc.Relations = count
|
|
result[agentID] = kc
|
|
}
|
|
return result, rows2.Err()
|
|
}
|
|
|
|
// agentChannelKey is a composite key for the merge map.
|
|
type agentChannelKey struct {
|
|
AgentID uuid.UUID // zero UUID for nil agent_id
|
|
Channel string
|
|
}
|
|
|
|
// findTotalsSnapshotIndex returns the index of the existing totals row
|
|
// (Provider=="" && Model=="") in snapshots matching key, or -1 if none found.
|
|
func findTotalsSnapshotIndex(snapshots []store.UsageSnapshot, key agentChannelKey) int {
|
|
for i, snap := range snapshots {
|
|
if snap.Provider != "" || snap.Model != "" {
|
|
continue
|
|
}
|
|
if snap.Channel != key.Channel {
|
|
continue
|
|
}
|
|
var agentID uuid.UUID
|
|
if snap.AgentID != nil {
|
|
agentID = *snap.AgentID
|
|
}
|
|
if agentID == key.AgentID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func mergeTraceAndSpanRows(
|
|
bucketStart time.Time,
|
|
traceRows []traceAggregate,
|
|
spanRows []spanAggregate,
|
|
memoryCounts map[uuid.UUID]agentMemoryCounts,
|
|
kgCounts map[uuid.UUID]agentKGCounts,
|
|
) []store.UsageSnapshot {
|
|
var snapshots []store.UsageSnapshot
|
|
seenAgents := make(map[agentChannelKey]bool)
|
|
|
|
// 1. Create "totals" rows from trace data (provider='', model='')
|
|
for _, tr := range traceRows {
|
|
key := agentChannelKey{Channel: tr.Channel}
|
|
if tr.AgentID != nil {
|
|
key.AgentID = *tr.AgentID
|
|
}
|
|
seenAgents[key] = true
|
|
|
|
snap := store.UsageSnapshot{
|
|
BucketHour: bucketStart,
|
|
AgentID: tr.AgentID,
|
|
Provider: "",
|
|
Model: "",
|
|
Channel: tr.Channel,
|
|
RequestCount: tr.RequestCount,
|
|
ErrorCount: tr.ErrorCount,
|
|
UniqueUsers: tr.UniqueUsers,
|
|
ToolCallCount: tr.ToolCallCount,
|
|
AvgDurationMS: tr.AvgDurationMS,
|
|
}
|
|
|
|
// Attach memory/KG counts to totals row
|
|
if tr.AgentID != nil {
|
|
if mc, ok := memoryCounts[*tr.AgentID]; ok {
|
|
snap.MemoryDocs = mc.Docs
|
|
snap.MemoryChunks = mc.Chunks
|
|
}
|
|
if kc, ok := kgCounts[*tr.AgentID]; ok {
|
|
snap.KGEntities = kc.Entities
|
|
snap.KGRelations = kc.Relations
|
|
}
|
|
}
|
|
|
|
snapshots = append(snapshots, snap)
|
|
}
|
|
|
|
// 2. Create detail rows from span data (with actual provider/model).
|
|
// Defense in depth: a span row with empty provider/model would collide
|
|
// with the totals row's conflict-target key (agent_id, provider='',
|
|
// model='', channel). Instead of appending a duplicate row, merge its
|
|
// metrics into the existing totals row for that (agent_id, channel).
|
|
for _, sp := range spanRows {
|
|
if sp.Provider == "" && sp.Model == "" {
|
|
key := agentChannelKey{Channel: sp.Channel}
|
|
if sp.AgentID != nil {
|
|
key.AgentID = *sp.AgentID
|
|
}
|
|
if idx := findTotalsSnapshotIndex(snapshots, key); idx >= 0 {
|
|
snapshots[idx].LLMCallCount += sp.LLMCallCount
|
|
snapshots[idx].InputTokens += sp.InputTokens
|
|
snapshots[idx].OutputTokens += sp.OutputTokens
|
|
snapshots[idx].TotalCost += sp.TotalCost
|
|
snapshots[idx].CacheReadTokens += sp.CacheReadTokens
|
|
snapshots[idx].CacheCreateTokens += sp.CacheCreateTokens
|
|
snapshots[idx].ThinkingTokens += sp.ThinkingTokens
|
|
continue
|
|
}
|
|
}
|
|
|
|
snapshots = append(snapshots, store.UsageSnapshot{
|
|
BucketHour: bucketStart,
|
|
AgentID: sp.AgentID,
|
|
Provider: sp.Provider,
|
|
Model: sp.Model,
|
|
Channel: sp.Channel,
|
|
LLMCallCount: sp.LLMCallCount,
|
|
InputTokens: sp.InputTokens,
|
|
OutputTokens: sp.OutputTokens,
|
|
TotalCost: sp.TotalCost,
|
|
CacheReadTokens: sp.CacheReadTokens,
|
|
CacheCreateTokens: sp.CacheCreateTokens,
|
|
ThinkingTokens: sp.ThinkingTokens,
|
|
})
|
|
}
|
|
|
|
// 3. Create memory/KG-only totals rows for agents without traces this hour
|
|
if memoryCounts != nil || kgCounts != nil {
|
|
allAgents := make(map[uuid.UUID]bool)
|
|
for id := range memoryCounts {
|
|
allAgents[id] = true
|
|
}
|
|
for id := range kgCounts {
|
|
allAgents[id] = true
|
|
}
|
|
for agentID := range allAgents {
|
|
key := agentChannelKey{AgentID: agentID}
|
|
if seenAgents[key] {
|
|
continue
|
|
}
|
|
aid := agentID
|
|
snap := store.UsageSnapshot{
|
|
BucketHour: bucketStart,
|
|
AgentID: &aid,
|
|
Provider: "",
|
|
Model: "",
|
|
Channel: "",
|
|
}
|
|
if mc, ok := memoryCounts[agentID]; ok {
|
|
snap.MemoryDocs = mc.Docs
|
|
snap.MemoryChunks = mc.Chunks
|
|
}
|
|
if kc, ok := kgCounts[agentID]; ok {
|
|
snap.KGEntities = kc.Entities
|
|
snap.KGRelations = kc.Relations
|
|
}
|
|
snapshots = append(snapshots, snap)
|
|
}
|
|
}
|
|
|
|
return snapshots
|
|
}
|