diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 04407342..e3f4c638 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -17,6 +17,10 @@ services: UPDATE_AGENT_URL: "${UPDATE_AGENT_URL:-}" UPDATE_AGENT_TOKEN: "${UPDATE_AGENT_TOKEN:-}" UPDATE_GITHUB_TOKEN: "${UPDATE_GITHUB_TOKEN:-}" + # Request-log retention: default 7 days / 20k rows. Raise both when the + # usage/cost panel needs a longer history (~0.7 KB/row; ~150 MB at 90d @ 1.5k rows/day). + CLI2API_LOG_RETENTION_DAYS: "${CLI2API_LOG_RETENTION_DAYS:-7}" + CLI2API_LOG_MAX_ROWS: "${CLI2API_LOG_MAX_ROWS:-20000}" volumes: - qoder-data:/data - "${CLI2API_UPDATER_SOCKET_DIR:-/tmp/cli2api-updater}:/run/cli2api-updater:ro" @@ -33,4 +37,4 @@ services: restart: unless-stopped volumes: - qoder-data: \ No newline at end of file + qoder-data: diff --git a/internal/logs/recorder.go b/internal/logs/recorder.go index 74ccb0b0..c1c5225c 100644 --- a/internal/logs/recorder.go +++ b/internal/logs/recorder.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log" + "os" + "strconv" "strings" "sync" "time" @@ -11,6 +13,38 @@ import ( "github.com/caigee-cmd/cli2api/internal/accounts" ) +// Request-log retention. Defaults preserve the historical behaviour (7 days / +// 20k rows); deployments that want a longer usage history can raise the +// retention window and the row cap together. +const ( + defaultRetentionDays = 7 + defaultMaxLogRows = 20_000 + retentionDaysEnv = "CLI2API_LOG_RETENTION_DAYS" + maxLogRowsEnv = "CLI2API_LOG_MAX_ROWS" +) + +// RetentionDays is how long request logs are kept; 0 or an invalid value falls +// back to the default. +func RetentionDays() int { + if v := strings.TrimSpace(os.Getenv(retentionDaysEnv)); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return defaultRetentionDays +} + +// MaxLogRows caps the retained rows independently of the age window, so a burst +// of traffic cannot grow the SQLite file without bound. +func MaxLogRows() int { + if v := strings.TrimSpace(os.Getenv(maxLogRowsEnv)); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return defaultMaxLogRows +} + // RequestPersister is the write surface the recorder actually uses. // Query methods stay off this interface so a later store package can // satisfy logs without logs depending on the concrete SQLite type. @@ -200,7 +234,8 @@ func (r *RequestRecorder) PurgeLoop(stop <-chan struct{}, every time.Duration) { func (r *RequestRecorder) purgeOnce() { r.enqueue(func() { - if _, err := r.store.PurgeRequestLogs(context.Background(), 7*24*time.Hour, 20_000); err != nil { + keep := time.Duration(RetentionDays()) * 24 * time.Hour + if _, err := r.store.PurgeRequestLogs(context.Background(), keep, MaxLogRows()); err != nil { logf("purge request logs: %v", err) } }) diff --git a/internal/logs/retention_test.go b/internal/logs/retention_test.go new file mode 100644 index 00000000..a7f50e23 --- /dev/null +++ b/internal/logs/retention_test.go @@ -0,0 +1,44 @@ +package logs + +import ( + "testing" +) + +func TestRetentionDaysDefaultsAndOverride(t *testing.T) { + t.Setenv(retentionDaysEnv, "") + if got := RetentionDays(); got != defaultRetentionDays { + t.Fatalf("default retention = %d, want %d", got, defaultRetentionDays) + } + + t.Setenv(retentionDaysEnv, "90") + if got := RetentionDays(); got != 90 { + t.Fatalf("override retention = %d, want 90", got) + } + + // Invalid values must not silently disable pruning. + for _, bad := range []string{"0", "-3", "abc", " "} { + t.Setenv(retentionDaysEnv, bad) + if got := RetentionDays(); got != defaultRetentionDays { + t.Fatalf("retention for %q = %d, want default %d", bad, got, defaultRetentionDays) + } + } +} + +func TestMaxLogRowsDefaultsAndOverride(t *testing.T) { + t.Setenv(maxLogRowsEnv, "") + if got := MaxLogRows(); got != defaultMaxLogRows { + t.Fatalf("default max rows = %d, want %d", got, defaultMaxLogRows) + } + + t.Setenv(maxLogRowsEnv, "200000") + if got := MaxLogRows(); got != 200000 { + t.Fatalf("override max rows = %d, want 200000", got) + } + + for _, bad := range []string{"0", "-1", "lots"} { + t.Setenv(maxLogRowsEnv, bad) + if got := MaxLogRows(); got != defaultMaxLogRows { + t.Fatalf("max rows for %q = %d, want default %d", bad, got, defaultMaxLogRows) + } + } +}