package auditlog import ( "context" "sort" "sync" ) // Logger is the interface for recording and querying audit log entries. // Log() must be non-blocking (backed by a buffered channel or in-memory store). type Logger interface { Log(entry AuditEntry) Query(ctx context.Context, q AuditQuery) (*AuditResult, error) QueryCosts(ctx context.Context, q CostQuery) (*CostResult, error) Start() Stop() } // ─── MemLogger ──────────────────────────────────────────────────────────────── // MemLogger is a thread-safe in-memory Logger used in tests. // It stores entries in insertion order and supports basic filtering. type MemLogger struct { mu sync.RWMutex entries []AuditEntry } // NewMemLogger creates a new MemLogger. func NewMemLogger() *MemLogger { return &MemLogger{} } func (m *MemLogger) Log(e AuditEntry) { m.mu.Lock() m.entries = append(m.entries, e) m.mu.Unlock() } // Entries returns a copy of all stored entries (safe to call from tests). func (m *MemLogger) Entries() []AuditEntry { m.mu.RLock() defer m.mu.RUnlock() out := make([]AuditEntry, len(m.entries)) copy(out, m.entries) return out } func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error) { m.mu.RLock() defer m.mu.RUnlock() sensitivityOrder := map[string]int{ "none": 0, "low": 1, "medium": 2, "high": 3, "critical": 4, } minLevel := sensitivityOrder[q.MinSensitivity] filtered := make([]AuditEntry, 0) for _, e := range m.entries { if e.TenantID != q.TenantID { continue } if q.UserID != "" && e.UserID != q.UserID { continue } if !q.StartTime.IsZero() && e.Timestamp.Before(q.StartTime) { continue } if !q.EndTime.IsZero() && e.Timestamp.After(q.EndTime) { continue } if q.Provider != "" && e.Provider != q.Provider { continue } if q.MinSensitivity != "" { if sensitivityOrder[e.SensitivityLevel] < minLevel { continue } } filtered = append(filtered, e) } total := len(filtered) if q.Offset < len(filtered) { filtered = filtered[q.Offset:] } else { filtered = make([]AuditEntry, 0) } limit := q.Limit if limit <= 0 { limit = 50 } else if limit > 10000 { limit = 10000 } if len(filtered) > limit { filtered = filtered[:limit] } return &AuditResult{Data: filtered, Total: total}, nil } func (m *MemLogger) QueryCosts(_ context.Context, q CostQuery) (*CostResult, error) { m.mu.RLock() defer m.mu.RUnlock() type aggKey = string type agg struct { tokens int cost float64 count int } totals := map[aggKey]*agg{} for _, e := range m.entries { if e.TenantID != q.TenantID { continue } if !q.StartTime.IsZero() && e.Timestamp.Before(q.StartTime) { continue } if !q.EndTime.IsZero() && e.Timestamp.After(q.EndTime) { continue } var key string switch q.GroupBy { case "model": key = e.ModelUsed case "department": key = e.Department default: key = e.Provider } if totals[key] == nil { totals[key] = &agg{} } totals[key].tokens += e.TokenTotal totals[key].cost += e.CostUSD totals[key].count++ } data := make([]CostSummary, 0) for k, v := range totals { data = append(data, CostSummary{ Key: k, TotalTokens: v.tokens, TotalCostUSD: v.cost, RequestCount: v.count, }) } sort.Slice(data, func(i, j int) bool { return data[i].Key < data[j].Key }) return &CostResult{Data: data}, nil } func (m *MemLogger) Start() {} func (m *MemLogger) Stop() {}