mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
Merge pull request #6424 from cyhhao/fix/bound-ops-system-log-storage
fix(ops): bound database-backed system log growth
This commit is contained in:
@@ -1613,10 +1613,10 @@ type OpsCleanupConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Schedule string `mapstructure:"schedule"`
|
||||
|
||||
// Retention days (0 disables that cleanup target).
|
||||
//
|
||||
// vNext requirement: default 30 days across ops datasets.
|
||||
// Retention days. Error and metrics targets accept 0 as an explicit truncate;
|
||||
// system logs require a positive value because their runtime setting is bounded.
|
||||
ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"`
|
||||
SystemLogRetentionDays int `mapstructure:"system_log_retention_days"`
|
||||
MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"`
|
||||
HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"`
|
||||
}
|
||||
@@ -2251,6 +2251,7 @@ func setDefaults() {
|
||||
viper.SetDefault("ops.cleanup.schedule", "0 2 * * *")
|
||||
// Retention days: vNext defaults to 30 days across ops datasets.
|
||||
viper.SetDefault("ops.cleanup.error_log_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.system_log_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30)
|
||||
viper.SetDefault("ops.aggregation.enabled", true)
|
||||
@@ -3674,6 +3675,9 @@ func (c *Config) Validate() error {
|
||||
if c.Ops.Cleanup.ErrorLogRetentionDays < 0 {
|
||||
return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative")
|
||||
}
|
||||
if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 {
|
||||
return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled")
|
||||
}
|
||||
if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 {
|
||||
return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative")
|
||||
}
|
||||
|
||||
@@ -1191,6 +1191,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpsCleanupConfig(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if !cfg.Ops.Cleanup.Enabled {
|
||||
t.Fatal("Ops.Cleanup.Enabled = false, want true")
|
||||
}
|
||||
if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 {
|
||||
t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsageCleanupConfigEnabled(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -2137,6 +2152,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 },
|
||||
wantErr: "ops.cleanup.error_log_retention_days",
|
||||
},
|
||||
{
|
||||
name: "ops cleanup system log retention",
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 },
|
||||
wantErr: "ops.cleanup.system_log_retention_days",
|
||||
},
|
||||
{
|
||||
name: "ops cleanup minute retention",
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 },
|
||||
|
||||
@@ -45,7 +45,8 @@ func TestComputeEffective_FallbackToCfgWhenSettingsAbsent(t *testing.T) {
|
||||
|
||||
svc.computeEffectiveLocked(context.Background())
|
||||
|
||||
if svc.effective != base {
|
||||
want := opsCleanupEffectiveConfig{OpsCleanupConfig: base, SystemLogRetentionDays: 30}
|
||||
if svc.effective != want {
|
||||
t.Fatalf("expected effective == cfg base, got %#v", svc.effective)
|
||||
}
|
||||
}
|
||||
@@ -70,12 +71,15 @@ func TestComputeEffective_SettingsOverridesAll(t *testing.T) {
|
||||
|
||||
svc.computeEffectiveLocked(context.Background())
|
||||
|
||||
want := config.OpsCleanupConfig{
|
||||
Enabled: true,
|
||||
Schedule: "0 * * * *",
|
||||
ErrorLogRetentionDays: 0,
|
||||
MinuteMetricsRetentionDays: 7,
|
||||
HourlyMetricsRetentionDays: 14,
|
||||
want := opsCleanupEffectiveConfig{
|
||||
OpsCleanupConfig: config.OpsCleanupConfig{
|
||||
Enabled: true,
|
||||
Schedule: "0 * * * *",
|
||||
ErrorLogRetentionDays: 0,
|
||||
MinuteMetricsRetentionDays: 7,
|
||||
HourlyMetricsRetentionDays: 14,
|
||||
},
|
||||
SystemLogRetentionDays: 30,
|
||||
}
|
||||
if svc.effective != want {
|
||||
t.Fatalf("effective mismatch:\nwant %#v\n got %#v", want, svc.effective)
|
||||
@@ -156,11 +160,72 @@ func TestComputeEffective_BadJSONFallsBackToCfg(t *testing.T) {
|
||||
|
||||
svc.computeEffectiveLocked(context.Background())
|
||||
|
||||
if svc.effective != base {
|
||||
want := opsCleanupEffectiveConfig{OpsCleanupConfig: base, SystemLogRetentionDays: 30}
|
||||
if svc.effective != want {
|
||||
t.Fatalf("expected fallback to cfg on bad JSON, got %#v", svc.effective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEffective_RuntimeConfigOwnsSystemLogRetention(t *testing.T) {
|
||||
repo := newRuntimeSettingRepoStub()
|
||||
writeAdvancedSettings(t, repo, OpsDataRetentionSettings{
|
||||
CleanupEnabled: true,
|
||||
CleanupSchedule: "0 2 * * *",
|
||||
ErrorLogRetentionDays: 90,
|
||||
})
|
||||
repo.values[SettingKeyOpsRuntimeLogConfig] = `{"retention_days":7}`
|
||||
svc := makeOverlayService(repo, config.OpsCleanupConfig{ErrorLogRetentionDays: 30})
|
||||
|
||||
svc.computeEffectiveLocked(context.Background())
|
||||
|
||||
if svc.effective.ErrorLogRetentionDays != 90 {
|
||||
t.Fatalf("error log retention = %d, want 90", svc.effective.ErrorLogRetentionDays)
|
||||
}
|
||||
if svc.effective.SystemLogRetentionDays != 7 {
|
||||
t.Fatalf("system log retention = %d, want 7", svc.effective.SystemLogRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEffective_InvalidRuntimeConfigDoesNotDiscardAdvancedSettings(t *testing.T) {
|
||||
repo := newRuntimeSettingRepoStub()
|
||||
writeAdvancedSettings(t, repo, OpsDataRetentionSettings{
|
||||
CleanupEnabled: true,
|
||||
CleanupSchedule: "0 * * * *",
|
||||
ErrorLogRetentionDays: 14,
|
||||
})
|
||||
repo.values[SettingKeyOpsRuntimeLogConfig] = `{invalid-json}`
|
||||
svc := makeOverlayService(repo, config.OpsCleanupConfig{ErrorLogRetentionDays: 30})
|
||||
|
||||
svc.computeEffectiveLocked(context.Background())
|
||||
|
||||
if !svc.effective.Enabled || svc.effective.ErrorLogRetentionDays != 14 {
|
||||
t.Fatalf("advanced settings were discarded: %#v", svc.effective)
|
||||
}
|
||||
if svc.effective.SystemLogRetentionDays != 30 {
|
||||
t.Fatalf("system log retention = %d, want default 30", svc.effective.SystemLogRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEffective_PartialAdvancedSettingsUseSafeDefaults(t *testing.T) {
|
||||
repo := newRuntimeSettingRepoStub()
|
||||
repo.values[SettingKeyOpsAdvancedSettings] = `{"data_retention":{"cleanup_enabled":true}}`
|
||||
svc := makeOverlayService(repo, config.OpsCleanupConfig{
|
||||
Schedule: "0 3 * * *",
|
||||
ErrorLogRetentionDays: 90,
|
||||
MinuteMetricsRetentionDays: 90,
|
||||
HourlyMetricsRetentionDays: 90,
|
||||
})
|
||||
|
||||
svc.computeEffectiveLocked(context.Background())
|
||||
|
||||
if svc.effective.Schedule != opsCleanupDefaultSchedule ||
|
||||
svc.effective.ErrorLogRetentionDays != 30 ||
|
||||
svc.effective.MinuteMetricsRetentionDays != 30 ||
|
||||
svc.effective.HourlyMetricsRetentionDays != 30 {
|
||||
t.Fatalf("partial settings did not use safe defaults: %#v", svc.effective)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 OpsService.UpdateOpsAdvancedSettings 写入后会调用 cleanupReloader.Reload。
|
||||
type fakeCleanupReloader struct {
|
||||
calls int
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -59,11 +58,16 @@ type OpsCleanupService struct {
|
||||
cron *cron.Cron
|
||||
started bool
|
||||
stopped bool
|
||||
effective config.OpsCleanupConfig
|
||||
effective opsCleanupEffectiveConfig
|
||||
|
||||
warnNoRedisOnce sync.Once
|
||||
}
|
||||
|
||||
type opsCleanupEffectiveConfig struct {
|
||||
config.OpsCleanupConfig
|
||||
SystemLogRetentionDays int
|
||||
}
|
||||
|
||||
func NewOpsCleanupService(
|
||||
opsRepo OpsRepository,
|
||||
db *sql.DB,
|
||||
@@ -166,9 +170,10 @@ func (s *OpsCleanupService) applyScheduleLocked(ctx context.Context) error {
|
||||
c.Start()
|
||||
s.cron = c
|
||||
logger.LegacyPrintf("service.ops_cleanup",
|
||||
"[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/min:%d/hour:%d)",
|
||||
"[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/system:%d/min:%d/hour:%d)",
|
||||
schedule, loc.String(),
|
||||
s.effective.ErrorLogRetentionDays,
|
||||
s.effective.SystemLogRetentionDays,
|
||||
s.effective.MinuteMetricsRetentionDays,
|
||||
s.effective.HourlyMetricsRetentionDays,
|
||||
)
|
||||
@@ -198,11 +203,14 @@ func (s *OpsCleanupService) Reload(ctx context.Context) error {
|
||||
// - Schedule:settings 非空时覆盖,否则保留 cfg
|
||||
// - *RetentionDays:settings >=0 时覆盖(包括 0=TRUNCATE),<0 沿用 cfg
|
||||
//
|
||||
// 若 settings 表无该 key(ErrSettingNotFound)或解析失败,整体 fallback 到 cfg.Ops.Cleanup。
|
||||
// System log retention has a separate owner: settings.ops_runtime_log_config.retention_days.
|
||||
// Missing or invalid settings fall back independently instead of disabling all cleanup overlays.
|
||||
func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) {
|
||||
base := config.OpsCleanupConfig{}
|
||||
base := opsCleanupEffectiveConfig{
|
||||
SystemLogRetentionDays: defaultOpsRuntimeLogConfig(s.cfg).RetentionDays,
|
||||
}
|
||||
if s.cfg != nil {
|
||||
base = s.cfg.Ops.Cleanup
|
||||
base.OpsCleanupConfig = s.cfg.Ops.Cleanup
|
||||
}
|
||||
defer func() { s.effective = base }()
|
||||
|
||||
@@ -212,38 +220,54 @@ func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsAdvancedSettings)
|
||||
values, err := s.settingRepo.GetMultiple(ctx, []string{
|
||||
SettingKeyOpsAdvancedSettings,
|
||||
SettingKeyOpsRuntimeLogConfig,
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrSettingNotFound) {
|
||||
logger.LegacyPrintf("service.ops_cleanup",
|
||||
"[OpsCleanup] read advanced settings failed, using cfg: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var adv OpsAdvancedSettings
|
||||
if err := json.Unmarshal([]byte(raw), &adv); err != nil {
|
||||
logger.LegacyPrintf("service.ops_cleanup",
|
||||
"[OpsCleanup] parse advanced settings failed, using cfg: %v", err)
|
||||
"[OpsCleanup] read cleanup settings failed, using defaults: %v", err)
|
||||
return
|
||||
}
|
||||
dr := adv.DataRetention
|
||||
base.Enabled = dr.CleanupEnabled
|
||||
if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" {
|
||||
base.Schedule = sched
|
||||
if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok {
|
||||
adv := defaultOpsAdvancedSettingsForConfig(s.cfg)
|
||||
if err := json.Unmarshal([]byte(raw), adv); err != nil {
|
||||
logger.LegacyPrintf("service.ops_cleanup",
|
||||
"[OpsCleanup] parse advanced settings failed, using cfg: %v", err)
|
||||
} else {
|
||||
dr := adv.DataRetention
|
||||
base.Enabled = dr.CleanupEnabled
|
||||
if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" {
|
||||
base.Schedule = sched
|
||||
}
|
||||
if dr.ErrorLogRetentionDays >= 0 {
|
||||
base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays
|
||||
}
|
||||
if dr.MinuteMetricsRetentionDays >= 0 {
|
||||
base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays
|
||||
}
|
||||
if dr.HourlyMetricsRetentionDays >= 0 {
|
||||
base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays
|
||||
}
|
||||
}
|
||||
}
|
||||
if dr.ErrorLogRetentionDays >= 0 {
|
||||
base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays
|
||||
}
|
||||
if dr.MinuteMetricsRetentionDays >= 0 {
|
||||
base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays
|
||||
}
|
||||
if dr.HourlyMetricsRetentionDays >= 0 {
|
||||
base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays
|
||||
if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok {
|
||||
defaults := defaultOpsRuntimeLogConfig(s.cfg)
|
||||
runtimeCfg := *defaults
|
||||
if err := json.Unmarshal([]byte(raw), &runtimeCfg); err != nil {
|
||||
logger.LegacyPrintf("service.ops_cleanup",
|
||||
"[OpsCleanup] parse runtime log config failed, using default system-log retention: %v", err)
|
||||
} else {
|
||||
normalizeOpsRuntimeLogConfig(&runtimeCfg, defaults)
|
||||
if runtimeCfg.RetentionDays >= 1 && runtimeCfg.RetentionDays <= 3650 {
|
||||
base.SystemLogRetentionDays = runtimeCfg.RetentionDays
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotEffective 取一份 effective 副本(runCleanupOnce 等读路径使用)。
|
||||
func (s *OpsCleanupService) snapshotEffective() config.OpsCleanupConfig {
|
||||
func (s *OpsCleanupService) snapshotEffective() opsCleanupEffectiveConfig {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.effective
|
||||
@@ -305,8 +329,8 @@ func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDelet
|
||||
{effective.ErrorLogRetentionDays, "ops_error_logs", "created_at", false, &out.errorLogs},
|
||||
{effective.ErrorLogRetentionDays, "ops_ingress_reject_aggregates", "bucket_start", false, &out.ingressRejects},
|
||||
{effective.ErrorLogRetentionDays, "ops_alert_events", "created_at", false, &out.alertEvents},
|
||||
{effective.ErrorLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs},
|
||||
{effective.ErrorLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits},
|
||||
{effective.SystemLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs},
|
||||
{effective.SystemLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits},
|
||||
{effective.MinuteMetricsRetentionDays, "ops_system_metrics", "created_at", false, &out.systemMetrics},
|
||||
{effective.HourlyMetricsRetentionDays, "ops_metrics_hourly", "bucket_start", false, &out.hourlyPreagg},
|
||||
{effective.HourlyMetricsRetentionDays, "ops_metrics_daily", "bucket_date", true, &out.dailyPreagg},
|
||||
|
||||
@@ -15,13 +15,14 @@ import (
|
||||
|
||||
func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig {
|
||||
out := &OpsRuntimeLogConfig{
|
||||
Level: "info",
|
||||
EnableSampling: false,
|
||||
SamplingInitial: 100,
|
||||
SamplingNext: 100,
|
||||
Caller: true,
|
||||
StacktraceLevel: "error",
|
||||
RetentionDays: 30,
|
||||
Level: "info",
|
||||
PersistAccessLogs: false,
|
||||
EnableSampling: false,
|
||||
SamplingInitial: 100,
|
||||
SamplingNext: 100,
|
||||
Caller: true,
|
||||
StacktraceLevel: "error",
|
||||
RetentionDays: 30,
|
||||
}
|
||||
if cfg == nil {
|
||||
return out
|
||||
@@ -32,8 +33,8 @@ func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig {
|
||||
out.SamplingNext = cfg.Log.Sampling.Thereafter
|
||||
out.Caller = cfg.Log.Caller
|
||||
out.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel))
|
||||
if cfg.Ops.Cleanup.ErrorLogRetentionDays > 0 {
|
||||
out.RetentionDays = cfg.Ops.Cleanup.ErrorLogRetentionDays
|
||||
if cfg.Ops.Cleanup.SystemLogRetentionDays > 0 {
|
||||
out.RetentionDays = cfg.Ops.Cleanup.SystemLogRetentionDays
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -144,7 +145,7 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyOpsRuntimeLogConfig(&next); err != nil {
|
||||
if err := s.applyRuntimeLogConfig(&next); err != nil {
|
||||
s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "apply_failed: "+err.Error())
|
||||
return nil, err
|
||||
}
|
||||
@@ -159,12 +160,13 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime
|
||||
}
|
||||
if err := s.settingRepo.Set(ctx, SettingKeyOpsRuntimeLogConfig, string(encoded)); err != nil {
|
||||
// 存储失败时回滚到旧配置,避免内存状态与持久化状态不一致。
|
||||
_ = applyOpsRuntimeLogConfig(oldCfg)
|
||||
_ = s.applyRuntimeLogConfig(oldCfg)
|
||||
s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "persist_failed: "+err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.auditRuntimeLogConfigChange(operatorID, oldCfg, &next, "updated")
|
||||
s.reloadOpsCleanup(ctx)
|
||||
|
||||
return &next, nil
|
||||
}
|
||||
@@ -191,14 +193,14 @@ func (s *OpsService) ResetRuntimeLogConfig(ctx context.Context, operatorID int64
|
||||
s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_validation_failed: "+err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if err := applyOpsRuntimeLogConfig(resetCfg); err != nil {
|
||||
if err := s.applyRuntimeLogConfig(resetCfg); err != nil {
|
||||
s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_apply_failed: "+err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 清理 runtime 覆盖配置,回退到 env/yaml baseline。
|
||||
if err := s.settingRepo.Delete(ctx, SettingKeyOpsRuntimeLogConfig); err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
_ = applyOpsRuntimeLogConfig(oldCfg)
|
||||
_ = s.applyRuntimeLogConfig(oldCfg)
|
||||
s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_persist_failed: "+err.Error())
|
||||
return nil, err
|
||||
}
|
||||
@@ -209,9 +211,29 @@ func (s *OpsService) ResetRuntimeLogConfig(ctx context.Context, operatorID int64
|
||||
resetCfg.UpdatedByUserID = operatorID
|
||||
|
||||
s.auditRuntimeLogConfigChange(operatorID, oldCfg, resetCfg, "reset")
|
||||
s.reloadOpsCleanup(ctx)
|
||||
return resetCfg, nil
|
||||
}
|
||||
|
||||
func (s *OpsService) applyRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error {
|
||||
if err := applyOpsRuntimeLogConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil && s.systemLogSink != nil {
|
||||
s.systemLogSink.SetPersistAccessLogs(cfg.PersistAccessLogs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OpsService) reloadOpsCleanup(ctx context.Context) {
|
||||
if s == nil || s.cleanupReloader == nil {
|
||||
return
|
||||
}
|
||||
if err := s.cleanupReloader.Reload(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.ops_log_runtime", "[OpsLogRuntime] cleanup reload failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func applyOpsRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("nil runtime log config")
|
||||
@@ -238,7 +260,7 @@ func (s *OpsService) applyRuntimeLogConfigOnStartup(ctx context.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = applyOpsRuntimeLogConfig(cfg)
|
||||
_ = s.applyRuntimeLogConfig(cfg)
|
||||
}
|
||||
|
||||
func (s *OpsService) auditRuntimeLogConfigChange(operatorID int64, oldCfg *OpsRuntimeLogConfig, newCfg *OpsRuntimeLogConfig, action string) {
|
||||
|
||||
@@ -21,6 +21,15 @@ type runtimeSettingRepoStub struct {
|
||||
deleteFn func(key string) error
|
||||
}
|
||||
|
||||
type runtimeCleanupReloader struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *runtimeCleanupReloader) Reload(context.Context) error {
|
||||
r.calls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func newRuntimeSettingRepoStub() *runtimeSettingRepoStub {
|
||||
return &runtimeSettingRepoStub{
|
||||
values: map[string]string{},
|
||||
@@ -181,7 +190,8 @@ func TestResetRuntimeLogConfig_ShouldFallbackToBaseline(t *testing.T) {
|
||||
},
|
||||
Ops: config.OpsConfig{
|
||||
Cleanup: config.OpsCleanupConfig{
|
||||
ErrorLogRetentionDays: 45,
|
||||
ErrorLogRetentionDays: 7,
|
||||
SystemLogRetentionDays: 45,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -282,7 +292,8 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := &OpsService{
|
||||
settingRepo: repo,
|
||||
settingRepo: repo,
|
||||
systemLogSink: &OpsSystemLogSink{},
|
||||
cfg: &config.Config{
|
||||
Log: config.LogConfig{
|
||||
Level: "info",
|
||||
@@ -311,13 +322,14 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{
|
||||
Level: "debug",
|
||||
EnableSampling: false,
|
||||
SamplingInitial: 100,
|
||||
SamplingNext: 100,
|
||||
Caller: true,
|
||||
StacktraceLevel: "error",
|
||||
RetentionDays: 30,
|
||||
Level: "debug",
|
||||
PersistAccessLogs: true,
|
||||
EnableSampling: false,
|
||||
SamplingInitial: 100,
|
||||
SamplingNext: 100,
|
||||
Caller: true,
|
||||
StacktraceLevel: "error",
|
||||
RetentionDays: 30,
|
||||
}, 5)
|
||||
if err == nil {
|
||||
t.Fatalf("expected persist error")
|
||||
@@ -326,15 +338,19 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) {
|
||||
if logger.CurrentLevel() != "info" {
|
||||
t.Fatalf("logger level should rollback to info, got %s", logger.CurrentLevel())
|
||||
}
|
||||
if svc.systemLogSink.persistAccessLogs.Load() {
|
||||
t.Fatal("access-log persistence should rollback after setting write failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRuntimeLogConfigOnStartup(t *testing.T) {
|
||||
repo := newRuntimeSettingRepoStub()
|
||||
cfgRaw := `{"level":"debug","enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}`
|
||||
cfgRaw := `{"level":"debug","persist_access_logs":true,"enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}`
|
||||
repo.values[SettingKeyOpsRuntimeLogConfig] = cfgRaw
|
||||
|
||||
svc := &OpsService{
|
||||
settingRepo: repo,
|
||||
settingRepo: repo,
|
||||
systemLogSink: &OpsSystemLogSink{},
|
||||
cfg: &config.Config{
|
||||
Log: config.LogConfig{
|
||||
Level: "info",
|
||||
@@ -366,6 +382,9 @@ func TestApplyRuntimeLogConfigOnStartup(t *testing.T) {
|
||||
if logger.CurrentLevel() != "debug" {
|
||||
t.Fatalf("expected startup apply debug, got %s", logger.CurrentLevel())
|
||||
}
|
||||
if !svc.systemLogSink.persistAccessLogs.Load() {
|
||||
t.Fatal("startup config did not enable access-log persistence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) {
|
||||
@@ -382,13 +401,17 @@ func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) {
|
||||
},
|
||||
Ops: config.OpsConfig{
|
||||
Cleanup: config.OpsCleanupConfig{
|
||||
ErrorLogRetentionDays: 7,
|
||||
ErrorLogRetentionDays: 7,
|
||||
SystemLogRetentionDays: 14,
|
||||
},
|
||||
},
|
||||
})
|
||||
if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 7 {
|
||||
if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 14 {
|
||||
t.Fatalf("unexpected defaults: %+v", defaults)
|
||||
}
|
||||
if defaults.PersistAccessLogs {
|
||||
t.Fatal("access-log persistence should be disabled by default")
|
||||
}
|
||||
|
||||
cfg := &OpsRuntimeLogConfig{
|
||||
Level: " ",
|
||||
@@ -403,7 +426,7 @@ func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) {
|
||||
if cfg.Level != "debug" || cfg.StacktraceLevel != "fatal" {
|
||||
t.Fatalf("normalize level/stacktrace failed: %+v", cfg)
|
||||
}
|
||||
if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 7 {
|
||||
if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 14 {
|
||||
t.Fatalf("normalize numeric defaults failed: %+v", cfg)
|
||||
}
|
||||
if err := validateOpsRuntimeLogConfig(cfg); err != nil {
|
||||
@@ -489,8 +512,11 @@ func TestUpdateRuntimeLogConfig_PreconditionErrors(t *testing.T) {
|
||||
|
||||
func TestUpdateRuntimeLogConfig_Success(t *testing.T) {
|
||||
repo := newRuntimeSettingRepoStub()
|
||||
reloader := &runtimeCleanupReloader{}
|
||||
svc := &OpsService{
|
||||
settingRepo: repo,
|
||||
settingRepo: repo,
|
||||
systemLogSink: &OpsSystemLogSink{},
|
||||
cleanupReloader: reloader,
|
||||
cfg: &config.Config{
|
||||
Log: config.LogConfig{
|
||||
Level: "info",
|
||||
@@ -519,13 +545,14 @@ func TestUpdateRuntimeLogConfig_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
next, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{
|
||||
Level: "debug",
|
||||
EnableSampling: false,
|
||||
SamplingInitial: 100,
|
||||
SamplingNext: 100,
|
||||
Caller: true,
|
||||
StacktraceLevel: "error",
|
||||
RetentionDays: 30,
|
||||
Level: "debug",
|
||||
PersistAccessLogs: true,
|
||||
EnableSampling: false,
|
||||
SamplingInitial: 100,
|
||||
SamplingNext: 100,
|
||||
Caller: true,
|
||||
StacktraceLevel: "error",
|
||||
RetentionDays: 30,
|
||||
}, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateRuntimeLogConfig() error: %v", err)
|
||||
@@ -536,6 +563,12 @@ func TestUpdateRuntimeLogConfig_Success(t *testing.T) {
|
||||
if logger.CurrentLevel() != "debug" {
|
||||
t.Fatalf("expected applied level debug, got %s", logger.CurrentLevel())
|
||||
}
|
||||
if !svc.systemLogSink.persistAccessLogs.Load() {
|
||||
t.Fatal("runtime update did not enable access-log persistence")
|
||||
}
|
||||
if reloader.calls != 1 {
|
||||
t.Fatalf("cleanup reload calls = %d, want 1", reloader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetRuntimeLogConfig_IgnoreNotFoundDelete(t *testing.T) {
|
||||
|
||||
@@ -114,28 +114,60 @@ func TestOpsRuntimeSettingsAdministrativeUpdatesAreImmediatelyVisible(t *testing
|
||||
}
|
||||
|
||||
func TestOpsRuntimeSettingsBackgroundRefreshConverges(t *testing.T) {
|
||||
repo := &opsRuntimeRefreshRepo{values: map[string]string{SettingKeyOpsMonitoringEnabled: "false"}}
|
||||
svc := &OpsService{settingRepo: repo}
|
||||
repo := &opsRuntimeRefreshRepo{values: map[string]string{
|
||||
SettingKeyOpsMonitoringEnabled: "false",
|
||||
SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":false}`,
|
||||
}}
|
||||
sink := &OpsSystemLogSink{}
|
||||
svc := &OpsService{settingRepo: repo, systemLogSink: sink}
|
||||
svc.initRuntimeSettings(context.Background())
|
||||
if svc.IsMonitoringEnabled(context.Background()) {
|
||||
t.Fatal("initial monitoring state = true, want false")
|
||||
}
|
||||
|
||||
repo.set(SettingKeyOpsMonitoringEnabled, "true")
|
||||
repo.set(SettingKeyOpsRuntimeLogConfig, `{"persist_access_logs":true}`)
|
||||
svc.startRuntimeSettingsRefresh(context.Background(), 5*time.Millisecond, 0, 50*time.Millisecond)
|
||||
t.Cleanup(svc.StopRuntimeSettingsRefresh)
|
||||
waitForOpsRefresh(t, time.Second, func() bool {
|
||||
return svc.IsMonitoringEnabled(context.Background()) && svc.RuntimeSettingsRefreshHealth().SuccessTotal > 0
|
||||
return svc.IsMonitoringEnabled(context.Background()) &&
|
||||
sink.persistAccessLogs.Load() &&
|
||||
svc.RuntimeSettingsRefreshHealth().SuccessTotal > 0
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpsRuntimeSettingsRefreshUsesSafeAccessLogDefault(t *testing.T) {
|
||||
repo := &opsRuntimeRefreshRepo{values: map[string]string{
|
||||
SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":true}`,
|
||||
}}
|
||||
sink := &OpsSystemLogSink{}
|
||||
svc := &OpsService{settingRepo: repo, systemLogSink: sink}
|
||||
svc.initRuntimeSettings(context.Background())
|
||||
if !sink.persistAccessLogs.Load() {
|
||||
t.Fatal("valid runtime config did not enable access-log persistence")
|
||||
}
|
||||
|
||||
repo.set(SettingKeyOpsRuntimeLogConfig, `{invalid-json}`)
|
||||
if err := svc.RefreshRuntimeSettings(context.Background()); err != nil {
|
||||
t.Fatalf("RefreshRuntimeSettings() error = %v", err)
|
||||
}
|
||||
if sink.persistAccessLogs.Load() {
|
||||
t.Fatal("invalid runtime config should fail closed for access-log persistence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsRuntimeSettingsRefreshFailuresKeepLastKnownGoodSnapshot(t *testing.T) {
|
||||
repo := &opsRuntimeRefreshRepo{values: map[string]string{
|
||||
SettingKeyOpsMonitoringEnabled: "false",
|
||||
SettingKeyOpsAdvancedSettings: `{"ignore_no_available_accounts":true}`,
|
||||
SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":true}`,
|
||||
}}
|
||||
svc := &OpsService{settingRepo: repo}
|
||||
sink := &OpsSystemLogSink{}
|
||||
svc := &OpsService{settingRepo: repo, systemLogSink: sink}
|
||||
svc.initRuntimeSettings(context.Background())
|
||||
if !sink.persistAccessLogs.Load() {
|
||||
t.Fatal("initial access-log setting was not applied")
|
||||
}
|
||||
repo.fail.Store(true)
|
||||
svc.startRuntimeSettingsRefresh(context.Background(), 5*time.Millisecond, 0, 50*time.Millisecond)
|
||||
t.Cleanup(svc.StopRuntimeSettingsRefresh)
|
||||
@@ -149,6 +181,9 @@ func TestOpsRuntimeSettingsRefreshFailuresKeepLastKnownGoodSnapshot(t *testing.T
|
||||
if !svc.OpsAdvancedSettingsSnapshot().IgnoreNoAvailableAccounts {
|
||||
t.Fatal("failed refresh overwrote last known advanced settings")
|
||||
}
|
||||
if !sink.persistAccessLogs.Load() {
|
||||
t.Fatal("failed refresh overwrote last known access-log setting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsRuntimeSettingsRefreshStopEndsLifecycle(t *testing.T) {
|
||||
|
||||
@@ -177,7 +177,7 @@ func (s *OpsService) initRuntimeSettings(ctx context.Context) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
defaults := defaultOpsAdvancedSettings()
|
||||
defaults := defaultOpsAdvancedSettingsForConfig(s.cfg)
|
||||
s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: true, advanced: *defaults})
|
||||
_ = s.RefreshRuntimeSettings(ctx)
|
||||
}
|
||||
@@ -198,6 +198,7 @@ func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error {
|
||||
values, err := s.settingRepo.GetMultiple(ctx, []string{
|
||||
SettingKeyOpsMonitoringEnabled,
|
||||
SettingKeyOpsAdvancedSettings,
|
||||
SettingKeyOpsRuntimeLogConfig,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -207,15 +208,27 @@ func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error {
|
||||
if raw, ok := values[SettingKeyOpsMonitoringEnabled]; ok {
|
||||
monitoringEnabled = parseOpsMonitoringEnabled(raw)
|
||||
}
|
||||
advanced := defaultOpsAdvancedSettings()
|
||||
advanced := defaultOpsAdvancedSettingsForConfig(s.cfg)
|
||||
if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok {
|
||||
if err := json.Unmarshal([]byte(raw), advanced); err != nil {
|
||||
advanced = defaultOpsAdvancedSettings()
|
||||
advanced = defaultOpsAdvancedSettingsForConfig(s.cfg)
|
||||
}
|
||||
}
|
||||
normalizeOpsAdvancedSettings(advanced)
|
||||
|
||||
s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: monitoringEnabled, advanced: *advanced})
|
||||
if s.systemLogSink != nil {
|
||||
persistAccessLogs := false
|
||||
if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok {
|
||||
var runtimeCfg struct {
|
||||
PersistAccessLogs bool `json:"persist_access_logs"`
|
||||
}
|
||||
if json.Unmarshal([]byte(raw), &runtimeCfg) == nil {
|
||||
persistAccessLogs = runtimeCfg.PersistAccessLogs
|
||||
}
|
||||
}
|
||||
s.systemLogSink.SetPersistAccessLogs(persistAccessLogs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -360,7 +373,7 @@ func (s *OpsService) SetMonitoringEnabled(enabled bool) {
|
||||
}
|
||||
s.runtimeSettingsMu.Lock()
|
||||
current := s.runtimeSettings.Load()
|
||||
next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettings()}
|
||||
next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettingsForConfig(s.cfg)}
|
||||
if current != nil {
|
||||
next.advanced = current.advanced
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -360,7 +362,9 @@ func (s *OpsService) UpdateOpsAlertRuntimeSettings(ctx context.Context, cfg *Ops
|
||||
func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
|
||||
return &OpsAdvancedSettings{
|
||||
DataRetention: OpsDataRetentionSettings{
|
||||
CleanupEnabled: false,
|
||||
// Keep the DB-backed default aligned with ops.cleanup.enabled so a
|
||||
// first settings read cannot silently disable configured retention.
|
||||
CleanupEnabled: true,
|
||||
CleanupSchedule: opsCleanupDefaultSchedule,
|
||||
ErrorLogRetentionDays: 30,
|
||||
MinuteMetricsRetentionDays: 30,
|
||||
@@ -382,6 +386,14 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultOpsAdvancedSettingsForConfig(cfg *config.Config) *OpsAdvancedSettings {
|
||||
defaults := defaultOpsAdvancedSettings()
|
||||
if cfg != nil {
|
||||
defaults.DataRetention.CleanupEnabled = cfg.Ops.Cleanup.Enabled
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) {
|
||||
if cfg == nil {
|
||||
return
|
||||
@@ -456,6 +468,7 @@ func (s *OpsService) OpsAdvancedSettingsSnapshot() OpsAdvancedSettings {
|
||||
if snapshot := s.runtimeSettings.Load(); snapshot != nil {
|
||||
return snapshot.advanced
|
||||
}
|
||||
return *defaultOpsAdvancedSettingsForConfig(s.cfg)
|
||||
}
|
||||
return *defaultOpsAdvancedSettings()
|
||||
}
|
||||
|
||||
@@ -23,11 +23,29 @@ func TestGetOpsAdvancedSettings_DefaultSnapshotHidesOpenAITokenStats(t *testing.
|
||||
if !cfg.DisplayAlertEvents {
|
||||
t.Fatalf("DisplayAlertEvents = false, want true by default")
|
||||
}
|
||||
if !cfg.DataRetention.CleanupEnabled {
|
||||
t.Fatal("data cleanup should be enabled by default")
|
||||
}
|
||||
if repo.getValueCalls != 0 || repo.getMultipleCalls != 0 {
|
||||
t.Fatalf("hot-path snapshot read touched repository: get=%d get_multiple=%d", repo.getValueCalls, repo.getMultipleCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOpsAdvancedSettings_DefaultCleanupFollowsDeploymentConfig(t *testing.T) {
|
||||
svc := &OpsService{cfg: &config.Config{Ops: config.OpsConfig{
|
||||
Cleanup: config.OpsCleanupConfig{Enabled: false},
|
||||
}}}
|
||||
svc.initRuntimeSettings(context.Background())
|
||||
|
||||
cfg, err := svc.GetOpsAdvancedSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetOpsAdvancedSettings() error = %v", err)
|
||||
}
|
||||
if cfg.DataRetention.CleanupEnabled {
|
||||
t.Fatal("data cleanup should follow the disabled deployment baseline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOpsAdvancedSettings_PersistsOpenAITokenStatsVisibility(t *testing.T) {
|
||||
repo := newRuntimeSettingRepoStub()
|
||||
svc := &OpsService{settingRepo: repo}
|
||||
|
||||
@@ -69,12 +69,14 @@ type OpsMetricThresholds struct {
|
||||
}
|
||||
|
||||
type OpsRuntimeLogConfig struct {
|
||||
Level string `json:"level"`
|
||||
EnableSampling bool `json:"enable_sampling"`
|
||||
SamplingInitial int `json:"sampling_initial"`
|
||||
SamplingNext int `json:"sampling_thereafter"`
|
||||
Caller bool `json:"caller"`
|
||||
StacktraceLevel string `json:"stacktrace_level"`
|
||||
Level string `json:"level"`
|
||||
PersistAccessLogs bool `json:"persist_access_logs"`
|
||||
EnableSampling bool `json:"enable_sampling"`
|
||||
SamplingInitial int `json:"sampling_initial"`
|
||||
SamplingNext int `json:"sampling_thereafter"`
|
||||
Caller bool `json:"caller"`
|
||||
StacktraceLevel string `json:"stacktrace_level"`
|
||||
// RetentionDays is the authoritative retention for ops_system_logs.
|
||||
RetentionDays int `json:"retention_days"`
|
||||
Source string `json:"source,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
|
||||
@@ -42,10 +42,11 @@ type OpsSystemLogSink struct {
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
droppedCount uint64
|
||||
writeFailed uint64
|
||||
writtenCount uint64
|
||||
totalDelayNs uint64
|
||||
droppedCount uint64
|
||||
writeFailed uint64
|
||||
writtenCount uint64
|
||||
totalDelayNs uint64
|
||||
persistAccessLogs atomic.Bool
|
||||
|
||||
lastError atomic.Value
|
||||
}
|
||||
@@ -147,6 +148,15 @@ func (s *OpsSystemLogSink) WriteLogEvent(event *logger.LogEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPersistAccessLogs controls whether high-volume request access logs are
|
||||
// copied into PostgreSQL. Warning/error and audit events are always retained.
|
||||
func (s *OpsSystemLogSink) SetPersistAccessLogs(enabled bool) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.persistAccessLogs.Store(enabled)
|
||||
}
|
||||
|
||||
func (s *OpsSystemLogSink) shouldIndex(event *logger.LogEvent) bool {
|
||||
if event != nil && event.Fields != nil {
|
||||
if skip, _ := event.Fields[logger.OpsSystemLogSkipField].(bool); skip {
|
||||
@@ -167,7 +177,7 @@ func (s *OpsSystemLogSink) shouldIndex(event *logger.LogEvent) bool {
|
||||
}
|
||||
}
|
||||
if strings.Contains(component, "http.access") {
|
||||
return true
|
||||
return s.persistAccessLogs.Load()
|
||||
}
|
||||
if strings.Contains(component, "audit") {
|
||||
return true
|
||||
|
||||
@@ -32,9 +32,9 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) {
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "access component",
|
||||
name: "access component disabled by default",
|
||||
event: &logger.LogEvent{Level: "info", Component: "http.access"},
|
||||
want: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "rejected access excluded from database sink",
|
||||
@@ -46,13 +46,13 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) {
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "access component from fields (real zap path)",
|
||||
name: "access component from fields disabled by default",
|
||||
event: &logger.LogEvent{
|
||||
Level: "info",
|
||||
Component: "",
|
||||
Fields: map[string]any{"component": "http.access"},
|
||||
},
|
||||
want: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "audit component",
|
||||
@@ -82,6 +82,28 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogSink_ShouldIndexAccessLogsOnlyWhenEnabled(t *testing.T) {
|
||||
sink := &OpsSystemLogSink{}
|
||||
sink.SetPersistAccessLogs(true)
|
||||
|
||||
if !sink.shouldIndex(&logger.LogEvent{Level: "info", Component: "http.access"}) {
|
||||
t.Fatal("access log should be indexed after explicit opt-in")
|
||||
}
|
||||
if !sink.shouldIndex(&logger.LogEvent{
|
||||
Level: "info",
|
||||
Fields: map[string]any{"component": "http.access"},
|
||||
}) {
|
||||
t.Fatal("field-based access log should be indexed after explicit opt-in")
|
||||
}
|
||||
if sink.shouldIndex(&logger.LogEvent{
|
||||
Level: "info",
|
||||
Component: "http.access",
|
||||
Fields: map[string]any{logger.OpsSystemLogSkipField: true},
|
||||
}) {
|
||||
t.Fatal("explicit skip marker must override access-log persistence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogSink_WriteLogEvent_ShouldDropWhenQueueFull(t *testing.T) {
|
||||
sink := &OpsSystemLogSink{
|
||||
queue: make(chan *logger.LogEvent, 1),
|
||||
|
||||
@@ -814,6 +814,7 @@ export interface OpsAggregationSettings {
|
||||
|
||||
export interface OpsRuntimeLogConfig {
|
||||
level: 'debug' | 'info' | 'warn' | 'error'
|
||||
persist_access_logs: boolean
|
||||
enable_sampling: boolean
|
||||
sampling_initial: number
|
||||
sampling_thereafter: number
|
||||
|
||||
@@ -42,8 +42,11 @@ export default {
|
||||
samplingInitial: 'Sampling initial',
|
||||
samplingThereafter: 'Sampling thereafter',
|
||||
retentionDays: 'Retention days',
|
||||
retentionDaysHint: 'Applied by the scheduled data-cleanup job.',
|
||||
caller: 'caller',
|
||||
sampling: 'sampling',
|
||||
persistAccessLogs: 'Store access logs in database',
|
||||
persistAccessLogsHint: 'Disabled by default because access logs add one indexed database row per request. Warning, error, and audit logs are always stored.',
|
||||
saveAndApply: 'Save and apply',
|
||||
resetDefaults: 'Reset defaults',
|
||||
latestWriteError: 'Latest write error:',
|
||||
|
||||
@@ -42,8 +42,11 @@ export default {
|
||||
samplingInitial: '采样初始条数',
|
||||
samplingThereafter: '后续采样间隔',
|
||||
retentionDays: '保留天数',
|
||||
retentionDaysHint: '由定时数据清理任务执行。',
|
||||
caller: '调用方',
|
||||
sampling: '采样',
|
||||
persistAccessLogs: '将访问日志写入数据库',
|
||||
persistAccessLogsHint: '默认关闭,因为访问日志会为每个请求新增一条带索引的数据库记录。警告、错误和审计日志始终会保留。',
|
||||
saveAndApply: '保存并应用',
|
||||
resetDefaults: '重置默认值',
|
||||
latestWriteError: '最近写入错误:',
|
||||
|
||||
@@ -41,6 +41,7 @@ const runtimeLoading = ref(false)
|
||||
const runtimeSaving = ref(false)
|
||||
const runtimeConfig = reactive<OpsRuntimeLogConfig>({
|
||||
level: 'info',
|
||||
persist_access_logs: false,
|
||||
enable_sampling: false,
|
||||
sampling_initial: 100,
|
||||
sampling_thereafter: 100,
|
||||
@@ -231,6 +232,7 @@ const loadRuntimeConfig = async () => {
|
||||
try {
|
||||
const cfg = await opsAPI.getRuntimeLogConfig()
|
||||
runtimeConfig.level = cfg.level
|
||||
runtimeConfig.persist_access_logs = cfg.persist_access_logs
|
||||
runtimeConfig.enable_sampling = cfg.enable_sampling
|
||||
runtimeConfig.sampling_initial = cfg.sampling_initial
|
||||
runtimeConfig.sampling_thereafter = cfg.sampling_thereafter
|
||||
@@ -249,6 +251,7 @@ const saveRuntimeConfig = async () => {
|
||||
try {
|
||||
const saved = await opsAPI.updateRuntimeLogConfig({ ...runtimeConfig })
|
||||
runtimeConfig.level = saved.level
|
||||
runtimeConfig.persist_access_logs = saved.persist_access_logs
|
||||
runtimeConfig.enable_sampling = saved.enable_sampling
|
||||
runtimeConfig.sampling_initial = saved.sampling_initial
|
||||
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
|
||||
@@ -272,6 +275,7 @@ const resetRuntimeConfig = async () => {
|
||||
try {
|
||||
const saved = await opsAPI.resetRuntimeLogConfig()
|
||||
runtimeConfig.level = saved.level
|
||||
runtimeConfig.persist_access_logs = saved.persist_access_logs
|
||||
runtimeConfig.enable_sampling = saved.enable_sampling
|
||||
runtimeConfig.sampling_initial = saved.sampling_initial
|
||||
runtimeConfig.sampling_thereafter = saved.sampling_thereafter
|
||||
@@ -419,6 +423,7 @@ onMounted(async () => {
|
||||
<label class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.systemLogs.retentionDays') }}
|
||||
<input v-model.number="runtimeConfig.retention_days" type="number" min="1" max="3650" class="input mt-1" />
|
||||
<span class="mt-1 block text-[11px] text-gray-500 dark:text-gray-400">{{ t('admin.ops.systemLogs.retentionDaysHint') }}</span>
|
||||
</label>
|
||||
<div class="md:col-span-2 xl:col-span-6">
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
@@ -431,6 +436,10 @@ onMounted(async () => {
|
||||
<input v-model="runtimeConfig.enable_sampling" type="checkbox" />
|
||||
{{ t('admin.ops.systemLogs.sampling') }}
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<input v-model="runtimeConfig.persist_access_logs" type="checkbox" />
|
||||
{{ t('admin.ops.systemLogs.persistAccessLogs') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="runtimeSaving" @click="saveRuntimeConfig">
|
||||
@@ -443,6 +452,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.systemLogs.persistAccessLogsHint') }}</p>
|
||||
<p v-if="health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400">{{ t('admin.ops.systemLogs.latestWriteError') }} {{ health.last_error }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ const PaginationStub = defineComponent({
|
||||
|
||||
const runtimeConfig = {
|
||||
level: 'info',
|
||||
persist_access_logs: false,
|
||||
enable_sampling: false,
|
||||
sampling_initial: 100,
|
||||
sampling_thereafter: 100,
|
||||
@@ -126,6 +127,22 @@ describe('OpsSystemLogTable host support', () => {
|
||||
expect(mockCleanupSystemLogs).toHaveBeenCalledWith(expect.objectContaining({ host: 'api-node-2' }))
|
||||
})
|
||||
|
||||
it('keeps database access-log persistence opt-in', async () => {
|
||||
const wrapper = mount(OpsSystemLogTable, {
|
||||
global: {
|
||||
stubs: {
|
||||
Select: SelectStub,
|
||||
Pagination: PaginationStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const label = wrapper.findAll('label').find((item) => item.text().includes('admin.ops.systemLogs.persistAccessLogs'))
|
||||
expect(label).toBeDefined()
|
||||
expect((label!.find('input').element as HTMLInputElement).checked).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh', zhLocale],
|
||||
['en', enLocale],
|
||||
|
||||
Reference in New Issue
Block a user