diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index 2ce102296a..85b43e2c0d 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -136,14 +136,28 @@ func (m *ConfigManager) Reload(ctx context.Context) error { previous := m.snapshot.Load() m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now}) m.configUntrusted.Store(false) - m.clearLoadError() + recovered := m.clearLoadError() m.logInvalidTokenEndpoints(previous, active) - LogInfo(EventConfigLoaded, map[string]any{ - "config_version": storage.ConfigVersion, "status": "loaded", - }) + // refreshLoop calls Reload every 5s, so logging every successful load turns + // config_loaded into a heartbeat that buries real config changes. + if recovered || shouldLogConfigLoaded(previous, storage, active) { + LogInfo(EventConfigLoaded, map[string]any{ + "config_version": storage.ConfigVersion, "status": "loaded", + }) + } return nil } +// shouldLogConfigLoaded reports whether a successful reload carries news: the +// first snapshot, a new config version (every admin save bumps it under the +// advisory lock in UpdateConfig) or a flip of the global risk control gate, +// which lives in its own setting and so leaves the version untouched. +func shouldLogConfigLoaded(previous *activeConfigSnapshot, storage storageConfig, active ActiveConfig) bool { + return previous == nil || + previous.storage.ConfigVersion != storage.ConfigVersion || + previous.active.RiskControlEnabled != active.RiskControlEnabled +} + // logInvalidTokenEndpoints warns once per change (not on every 5s refresh) // when stored endpoint tokens cannot be decrypted with the current key. func (m *ConfigManager) logInvalidTokenEndpoints(previous *activeConfigSnapshot, active ActiveConfig) { @@ -490,11 +504,15 @@ func (m *ConfigManager) recordLoadError(_ error) { m.stateMu.Unlock() } -func (m *ConfigManager) clearLoadError() { +// clearLoadError drops the recorded load failure and reports whether one was +// pending, so callers can tell a recovery apart from an unchanged reload. +func (m *ConfigManager) clearLoadError() bool { m.stateMu.Lock() + recovered := m.lastLoadError != "" m.lastLoadError = "" m.lastErrorAt = nil m.stateMu.Unlock() + return recovered } func cloneStorageConfig(cfg storageConfig) storageConfig { diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index 48175947c2..eb7bfcfa02 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -1,9 +1,11 @@ package securityaudit import ( + "bytes" "context" "encoding/json" "errors" + "log/slog" "strings" "testing" @@ -454,3 +456,51 @@ func TestUpdateConfigStrictBoundsAndKnownValues(t *testing.T) { }) } } + +// Regression coverage for issue #5732: refreshLoop reloads every 5s, so +// config_loaded must stay a change signal instead of ~17k identical lines a +// day, while still reporting the first load, real config changes and a +// recovery from a failed reload. +func TestConfigLoadedIsLoggedOnlyWhenSomethingChanged(t *testing.T) { + storage := DefaultStorageConfig() + storage.ConfigVersion = 4 + raw, err := json.Marshal(storage) + require.NoError(t, err) + repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{ + SettingKeyPromptAuditConfig: string(raw), + SettingKeyRiskControl: "false", + }}} + manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig()) + + var output bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&output, nil))) + t.Cleanup(func() { slog.SetDefault(previous) }) + loadedCount := func() int { return strings.Count(output.String(), EventConfigLoaded) } + + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 1, loadedCount(), "the first successful load must be logged") + + require.NoError(t, manager.Reload(context.Background())) + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 1, loadedCount(), "TTL refreshes of an unchanged config must stay silent") + + repository.values[SettingKeyRiskControl] = "true" + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 2, loadedCount(), "flipping the global risk control gate must be logged") + + storage.ConfigVersion = 5 + raw, err = json.Marshal(storage) + require.NoError(t, err) + repository.values[SettingKeyPromptAuditConfig] = string(raw) + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 3, loadedCount(), "a new config version must be logged") + + repository.loadErr = errors.New("settings unavailable") + require.Error(t, manager.Reload(context.Background())) + require.Equal(t, 3, loadedCount(), "a failed reload must not claim a load") + + repository.loadErr = nil + require.NoError(t, manager.Reload(context.Background())) + require.Equal(t, 4, loadedCount(), "recovering from a failed reload must be visible") +}