veylant/internal/auditlog/logger.go
2026-02-23 13:35:04 +01:00

151 lines
3.4 KiB
Go

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]
var filtered []AuditEntry
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 = nil
}
limit := q.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
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++
}
var data []CostSummary
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() {}