Files
coder/enterprise/aiseats/tracker.go
T
Steven Masley 93b9d70a9b chore: add audit log entry when ai seat is consumed (#22683)
When an ai seat is consumed, an audit log entry is made. This only happens the first time a seat is used.
2026-03-16 15:30:25 -05:00

117 lines
3.7 KiB
Go

package aiseats
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"cdr.dev/slog/v3"
agplaiseats "github.com/coder/coder/v2/coderd/aiseats"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/quartz"
)
type store interface {
UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error)
}
// throttleInterval is the minimum time between DB writes for the same user. This
// is to prevent ai seat tracking from consuming more db resources.
//
// These events are not critical to be recorded in real time, so we can afford to
// skip almost all of them. The first write is the most important, as it
// indicates a seat is consumed. Subsequent writes are purely informative and has
// no functional impact.
const (
throttleInterval = 6 * time.Hour
// failedThrottleInterval exists to prevent a transient error from causing no
// usage to be recorded. Still debounce.
failedThrottleInterval = 30 * time.Minute
)
// SeatTracker records current AI seat state for users.
type SeatTracker struct {
db store
logger slog.Logger
clock quartz.Clock
auditor *atomic.Pointer[audit.Auditor]
mu sync.RWMutex
retryAfter map[uuid.UUID]time.Time
}
func New(db store, logger slog.Logger, clock quartz.Clock, auditor *atomic.Pointer[audit.Auditor]) *SeatTracker {
if clock == nil {
clock = quartz.NewReal()
}
return &SeatTracker{db: db, logger: logger, clock: clock, auditor: auditor, retryAfter: make(map[uuid.UUID]time.Time)}
}
// skipRecord returns true when the user is still in the retry cooldown
// window and we should skip a DB write attempt.
func (t *SeatTracker) skipRecord(userID uuid.UUID, now time.Time) bool {
t.mu.RLock()
defer t.mu.RUnlock()
retryAfter, ok := t.retryAfter[userID]
return ok && now.Before(retryAfter)
}
// recordThrottle sets the next time when DB writes for this user are allowed.
func (t *SeatTracker) recordThrottle(userID uuid.UUID, now time.Time, d time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
t.retryAfter[userID] = now.Add(d)
}
// RecordUsage will record the AI seat usage for the user. There is a race condition between
// checking if the user should be recorded or throttled and actually recording. This is fine, as
// it just means we record the usage twice.
// The throttle just exists to prevent excessive database queries.
func (t *SeatTracker) RecordUsage(ctx context.Context, userID uuid.UUID, reason agplaiseats.Reason) {
now := t.clock.Now()
if t.skipRecord(userID, now) {
return
}
isNew, err := t.db.UpsertAISeatState(ctx, database.UpsertAISeatStateParams{
UserID: userID,
FirstUsedAt: now,
LastEventType: reason.EventType,
LastEventDescription: reason.Description,
})
if err != nil {
t.logger.Warn(ctx, "upsert AI seat state", slog.Error(err), slog.F("user_id", userID), slog.F("event_type", reason.EventType))
t.recordThrottle(userID, now, failedThrottleInterval)
return
}
t.recordThrottle(userID, now, throttleInterval)
if isNew && t.auditor != nil {
// Record an audit log for the first time a user uses an AI seat.
auditor := t.auditor.Load()
if auditor == nil || *auditor == nil {
return
}
audit.BackgroundAudit[database.AiSeatState](ctx, &audit.BackgroundAuditParams[database.AiSeatState]{
Audit: *auditor,
Log: t.logger,
UserID: userID,
Time: now,
Action: database.AuditActionCreate,
New: database.AiSeatState{
UserID: userID,
FirstUsedAt: now,
LastUsedAt: now,
LastEventType: reason.EventType,
LastEventDescription: reason.Description,
UpdatedAt: now,
},
})
}
}