ops_service.go 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "log"
  8. "math/rand/v2"
  9. "strings"
  10. "sync"
  11. "sync/atomic"
  12. "time"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  15. )
  16. var ErrOpsDisabled = infraerrors.NotFound("OPS_DISABLED", "Ops monitoring is disabled")
  17. const (
  18. opsMaxStoredErrorBodyBytes = 20 * 1024
  19. // OpsErrorLogQueueBodyMaxBytes bounds attacker-controlled response data while
  20. // it waits in the asynchronous error-log queue.
  21. OpsErrorLogQueueBodyMaxBytes = 8 * 1024
  22. opsRuntimeSettingsRefreshInterval = 30 * time.Second
  23. opsRuntimeSettingsRefreshJitter = 20
  24. opsRuntimeSettingsRefreshTimeout = 3 * time.Second
  25. opsRuntimeSettingsFailureLogEvery = time.Minute
  26. )
  27. type opsRuntimeSettingsSnapshot struct {
  28. monitoringEnabled bool
  29. advanced OpsAdvancedSettings
  30. }
  31. type OpsRuntimeSettingsRefreshHealth struct {
  32. Running bool `json:"running"`
  33. SuccessTotal uint64 `json:"success_total"`
  34. FailureTotal uint64 `json:"failure_total"`
  35. }
  36. // OpsService provides ingestion and query APIs for the Ops monitoring module.
  37. type OpsService struct {
  38. opsRepo OpsRepository
  39. settingRepo SettingRepository
  40. cfg *config.Config
  41. accountRepo AccountRepository
  42. userRepo UserRepository
  43. // getAccountAvailability is a unit-test hook for overriding account availability lookup.
  44. getAccountAvailability func(ctx context.Context, platformFilter string, groupIDFilter *int64) (*OpsAccountAvailability, error)
  45. concurrencyService *ConcurrencyService
  46. gatewayService *GatewayService
  47. openAIGatewayService *OpenAIGatewayService
  48. geminiCompatService *GeminiMessagesCompatService
  49. antigravityGatewayService *AntigravityGatewayService
  50. systemLogSink *OpsSystemLogSink
  51. ingressRejectAggregator *OpsIngressRejectAggregator
  52. authCacheInvalidationWorker *AuthCacheInvalidationWorker
  53. apiKeyService *APIKeyService
  54. // cleanupReloader 由 wire 在 OpsCleanupService 构造完成后通过 SetCleanupReloader 注入。
  55. // 解耦避免 OpsService -> OpsCleanupService 的硬依赖(cleanup 也读 settings,会循环)。
  56. cleanupReloader CleanupReloader
  57. // quotaAutoPauseSink 由 wire 注入(通常是 SettingService.SetOpenAIQuotaAutoPauseSettings)。
  58. // UpdateOpsAdvancedSettings 写入新配置后调用,把最新的 quota auto-pause 全局默认阈值
  59. // 立即同步到调度热路径读取的内存缓存,避免下次请求才能感知新值。
  60. quotaAutoPauseSink func(OpsOpenAIAccountQuotaAutoPauseSettings)
  61. // Published snapshots are immutable. Gateway reads are lock-free; the mutex
  62. // only serializes startup and administrative updates.
  63. runtimeSettings atomic.Pointer[opsRuntimeSettingsSnapshot]
  64. runtimeSettingsMu sync.Mutex
  65. runtimeRefreshMu sync.Mutex
  66. runtimeRefreshCancel context.CancelFunc
  67. runtimeRefreshDone chan struct{}
  68. runtimeRefreshRunning atomic.Bool
  69. runtimeRefreshSuccess atomic.Uint64
  70. runtimeRefreshFailure atomic.Uint64
  71. runtimeRefreshLastFailureLog atomic.Int64
  72. }
  73. // CleanupReloader 由 OpsCleanupService 实现。
  74. // UpdateOpsAdvancedSettings 写入新配置后调用 Reload,让 schedule/enabled 改动立刻生效。
  75. type CleanupReloader interface {
  76. Reload(ctx context.Context) error
  77. }
  78. // SetCleanupReloader 由 wire 注入 cleanup hook(构造期循环依赖的解耦点)。
  79. func (s *OpsService) SetCleanupReloader(r CleanupReloader) {
  80. if s == nil {
  81. return
  82. }
  83. s.cleanupReloader = r
  84. }
  85. // SetOpenAIQuotaAutoPauseSettingsSink 由 wire 注入,把最新的 quota auto-pause 全局默认
  86. // 阈值 push 到调度热路径读取的内存缓存。同 SetCleanupReloader 的解耦目的:避免 OpsService
  87. // 持有 *SettingService 引入循环依赖。
  88. func (s *OpsService) SetOpenAIQuotaAutoPauseSettingsSink(sink func(OpsOpenAIAccountQuotaAutoPauseSettings)) {
  89. if s == nil {
  90. return
  91. }
  92. s.quotaAutoPauseSink = sink
  93. }
  94. func NewOpsService(
  95. opsRepo OpsRepository,
  96. settingRepo SettingRepository,
  97. cfg *config.Config,
  98. accountRepo AccountRepository,
  99. userRepo UserRepository,
  100. concurrencyService *ConcurrencyService,
  101. gatewayService *GatewayService,
  102. openAIGatewayService *OpenAIGatewayService,
  103. geminiCompatService *GeminiMessagesCompatService,
  104. antigravityGatewayService *AntigravityGatewayService,
  105. systemLogSink *OpsSystemLogSink,
  106. ) *OpsService {
  107. svc := &OpsService{
  108. opsRepo: opsRepo,
  109. settingRepo: settingRepo,
  110. cfg: cfg,
  111. accountRepo: accountRepo,
  112. userRepo: userRepo,
  113. concurrencyService: concurrencyService,
  114. gatewayService: gatewayService,
  115. openAIGatewayService: openAIGatewayService,
  116. geminiCompatService: geminiCompatService,
  117. antigravityGatewayService: antigravityGatewayService,
  118. systemLogSink: systemLogSink,
  119. }
  120. svc.initRuntimeSettings(context.Background())
  121. svc.applyRuntimeLogConfigOnStartup(context.Background())
  122. return svc
  123. }
  124. func (s *OpsService) RequireMonitoringEnabled(ctx context.Context) error {
  125. if s.IsMonitoringEnabled(ctx) {
  126. return nil
  127. }
  128. return ErrOpsDisabled
  129. }
  130. func (s *OpsService) IsMonitoringEnabled(ctx context.Context) bool {
  131. _ = ctx
  132. // Hard switch: disable ops entirely.
  133. if s.cfg != nil && !s.cfg.Ops.Enabled {
  134. return false
  135. }
  136. if snapshot := s.runtimeSettings.Load(); snapshot != nil {
  137. return snapshot.monitoringEnabled
  138. }
  139. // Directly assembled test services and failed cold loads remain fail-open,
  140. // without turning a request into a settings-table lookup.
  141. return true
  142. }
  143. func parseOpsMonitoringEnabled(value string) bool {
  144. switch strings.ToLower(strings.TrimSpace(value)) {
  145. case "false", "0", "off", "disabled":
  146. return false
  147. default:
  148. return true
  149. }
  150. }
  151. func (s *OpsService) initRuntimeSettings(ctx context.Context) {
  152. if s == nil {
  153. return
  154. }
  155. defaults := defaultOpsAdvancedSettingsForConfig(s.cfg)
  156. s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: true, advanced: *defaults})
  157. _ = s.RefreshRuntimeSettings(ctx)
  158. }
  159. // RefreshRuntimeSettings is the cold-path database load used at startup and by
  160. // explicit administrative refreshes. Request processing only reads the atomic
  161. // snapshot.
  162. func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error {
  163. if s == nil || s.settingRepo == nil {
  164. return nil
  165. }
  166. if ctx == nil {
  167. ctx = context.Background()
  168. }
  169. s.runtimeSettingsMu.Lock()
  170. defer s.runtimeSettingsMu.Unlock()
  171. values, err := s.settingRepo.GetMultiple(ctx, []string{
  172. SettingKeyOpsMonitoringEnabled,
  173. SettingKeyOpsAdvancedSettings,
  174. SettingKeyOpsRuntimeLogConfig,
  175. })
  176. if err != nil {
  177. return err
  178. }
  179. monitoringEnabled := true
  180. if raw, ok := values[SettingKeyOpsMonitoringEnabled]; ok {
  181. monitoringEnabled = parseOpsMonitoringEnabled(raw)
  182. }
  183. advanced := defaultOpsAdvancedSettingsForConfig(s.cfg)
  184. if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok {
  185. if err := json.Unmarshal([]byte(raw), advanced); err != nil {
  186. advanced = defaultOpsAdvancedSettingsForConfig(s.cfg)
  187. }
  188. }
  189. normalizeOpsAdvancedSettings(advanced)
  190. s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: monitoringEnabled, advanced: *advanced})
  191. if s.systemLogSink != nil {
  192. persistAccessLogs := false
  193. if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok {
  194. var runtimeCfg struct {
  195. PersistAccessLogs bool `json:"persist_access_logs"`
  196. }
  197. if json.Unmarshal([]byte(raw), &runtimeCfg) == nil {
  198. persistAccessLogs = runtimeCfg.PersistAccessLogs
  199. }
  200. }
  201. s.systemLogSink.SetPersistAccessLogs(persistAccessLogs)
  202. }
  203. return nil
  204. }
  205. // StartRuntimeSettingsRefresh keeps DB-backed Ops settings converged across
  206. // application instances without putting database I/O on request paths.
  207. func (s *OpsService) StartRuntimeSettingsRefresh(ctx context.Context) {
  208. s.startRuntimeSettingsRefresh(ctx, opsRuntimeSettingsRefreshInterval, opsRuntimeSettingsRefreshJitter, opsRuntimeSettingsRefreshTimeout)
  209. }
  210. func (s *OpsService) startRuntimeSettingsRefresh(ctx context.Context, interval time.Duration, jitterPercent int, timeout time.Duration) {
  211. if s == nil || s.settingRepo == nil {
  212. return
  213. }
  214. if ctx == nil {
  215. ctx = context.Background()
  216. }
  217. if interval <= 0 {
  218. interval = opsRuntimeSettingsRefreshInterval
  219. }
  220. if timeout <= 0 {
  221. timeout = opsRuntimeSettingsRefreshTimeout
  222. }
  223. if jitterPercent < 0 {
  224. jitterPercent = 0
  225. }
  226. if jitterPercent > 100 {
  227. jitterPercent = 100
  228. }
  229. s.runtimeRefreshMu.Lock()
  230. if s.runtimeRefreshCancel != nil {
  231. s.runtimeRefreshMu.Unlock()
  232. return
  233. }
  234. refreshCtx, cancel := context.WithCancel(ctx)
  235. done := make(chan struct{})
  236. s.runtimeRefreshCancel = cancel
  237. s.runtimeRefreshDone = done
  238. s.runtimeRefreshRunning.Store(true)
  239. s.runtimeRefreshMu.Unlock()
  240. go func() {
  241. defer close(done)
  242. defer s.runtimeRefreshRunning.Store(false)
  243. for {
  244. delay := jitterDuration(interval, jitterPercent)
  245. timer := time.NewTimer(delay)
  246. select {
  247. case <-refreshCtx.Done():
  248. if !timer.Stop() {
  249. select {
  250. case <-timer.C:
  251. default:
  252. }
  253. }
  254. return
  255. case <-timer.C:
  256. }
  257. attemptCtx, attemptCancel := context.WithTimeout(refreshCtx, timeout)
  258. err := s.RefreshRuntimeSettings(attemptCtx)
  259. attemptCancel()
  260. if err != nil {
  261. s.runtimeRefreshFailure.Add(1)
  262. s.logRuntimeSettingsRefreshFailure(err)
  263. continue
  264. }
  265. s.runtimeRefreshSuccess.Add(1)
  266. }
  267. }()
  268. }
  269. func jitterDuration(base time.Duration, percent int) time.Duration {
  270. if base <= 0 || percent <= 0 {
  271. return base
  272. }
  273. delta := float64(percent) / 100
  274. factor := 1 - delta + rand.Float64()*(2*delta)
  275. if factor <= 0 {
  276. return base
  277. }
  278. return time.Duration(float64(base) * factor)
  279. }
  280. func (s *OpsService) logRuntimeSettingsRefreshFailure(err error) {
  281. if s == nil || err == nil {
  282. return
  283. }
  284. now := time.Now().Unix()
  285. for {
  286. last := s.runtimeRefreshLastFailureLog.Load()
  287. if last != 0 && now-last < int64(opsRuntimeSettingsFailureLogEvery/time.Second) {
  288. return
  289. }
  290. if s.runtimeRefreshLastFailureLog.CompareAndSwap(last, now) {
  291. log.Printf("[Ops] runtime settings refresh failed: %v", err)
  292. return
  293. }
  294. }
  295. }
  296. // StopRuntimeSettingsRefresh is idempotent and waits for an in-flight refresh
  297. // to observe cancellation before returning.
  298. func (s *OpsService) StopRuntimeSettingsRefresh() {
  299. if s == nil {
  300. return
  301. }
  302. s.runtimeRefreshMu.Lock()
  303. cancel := s.runtimeRefreshCancel
  304. done := s.runtimeRefreshDone
  305. s.runtimeRefreshMu.Unlock()
  306. if cancel != nil {
  307. cancel()
  308. }
  309. if done != nil {
  310. <-done
  311. }
  312. s.runtimeRefreshMu.Lock()
  313. if s.runtimeRefreshDone == done {
  314. s.runtimeRefreshCancel = nil
  315. s.runtimeRefreshDone = nil
  316. }
  317. s.runtimeRefreshMu.Unlock()
  318. }
  319. func (s *OpsService) RuntimeSettingsRefreshHealth() OpsRuntimeSettingsRefreshHealth {
  320. if s == nil {
  321. return OpsRuntimeSettingsRefreshHealth{}
  322. }
  323. return OpsRuntimeSettingsRefreshHealth{
  324. Running: s.runtimeRefreshRunning.Load(),
  325. SuccessTotal: s.runtimeRefreshSuccess.Load(),
  326. FailureTotal: s.runtimeRefreshFailure.Load(),
  327. }
  328. }
  329. // SetMonitoringEnabled publishes an already-persisted admin setting without a
  330. // database round trip.
  331. func (s *OpsService) SetMonitoringEnabled(enabled bool) {
  332. if s == nil {
  333. return
  334. }
  335. s.runtimeSettingsMu.Lock()
  336. current := s.runtimeSettings.Load()
  337. next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettingsForConfig(s.cfg)}
  338. if current != nil {
  339. next.advanced = current.advanced
  340. }
  341. s.runtimeSettings.Store(next)
  342. s.runtimeSettingsMu.Unlock()
  343. }
  344. func (s *OpsService) storeAdvancedSettingsSnapshot(cfg *OpsAdvancedSettings) {
  345. if s == nil || cfg == nil {
  346. return
  347. }
  348. s.runtimeSettingsMu.Lock()
  349. current := s.runtimeSettings.Load()
  350. next := &opsRuntimeSettingsSnapshot{monitoringEnabled: true, advanced: *cfg}
  351. if current != nil {
  352. next.monitoringEnabled = current.monitoringEnabled
  353. }
  354. s.runtimeSettings.Store(next)
  355. s.runtimeSettingsMu.Unlock()
  356. }
  357. // SanitizeOpsErrorBodyForQueue removes credentials and truncates the body
  358. // before it can consume capacity in the asynchronous queue.
  359. func SanitizeOpsErrorBodyForQueue(raw string) (string, bool) {
  360. return sanitizeErrorBodyForStorage(raw, OpsErrorLogQueueBodyMaxBytes)
  361. }
  362. // SanitizeOpsUpstreamErrorsForQueue bounds and serializes attempt-level data
  363. // before the entry can consume asynchronous queue capacity.
  364. func SanitizeOpsUpstreamErrorsForQueue(entry *OpsInsertErrorLogInput) error {
  365. return sanitizeOpsUpstreamErrors(entry)
  366. }
  367. func (s *OpsService) RecordError(ctx context.Context, entry *OpsInsertErrorLogInput) error {
  368. prepared, ok, err := s.prepareErrorLogInput(ctx, entry)
  369. if err != nil {
  370. log.Printf("[Ops] RecordError prepare failed: %v", err)
  371. return err
  372. }
  373. if !ok {
  374. return nil
  375. }
  376. if _, err := s.opsRepo.InsertErrorLog(ctx, prepared); err != nil {
  377. // Never bubble up to gateway; best-effort logging.
  378. log.Printf("[Ops] RecordError failed: %v", err)
  379. return err
  380. }
  381. return nil
  382. }
  383. func (s *OpsService) RecordErrorBatch(ctx context.Context, entries []*OpsInsertErrorLogInput) error {
  384. if len(entries) == 0 {
  385. return nil
  386. }
  387. prepared := make([]*OpsInsertErrorLogInput, 0, len(entries))
  388. for _, entry := range entries {
  389. item, ok, err := s.prepareErrorLogInput(ctx, entry)
  390. if err != nil {
  391. log.Printf("[Ops] RecordErrorBatch prepare failed: %v", err)
  392. continue
  393. }
  394. if ok {
  395. prepared = append(prepared, item)
  396. }
  397. }
  398. if len(prepared) == 0 {
  399. return nil
  400. }
  401. if len(prepared) == 1 {
  402. _, err := s.opsRepo.InsertErrorLog(ctx, prepared[0])
  403. if err != nil {
  404. log.Printf("[Ops] RecordErrorBatch single insert failed: %v", err)
  405. }
  406. return err
  407. }
  408. if _, err := s.opsRepo.BatchInsertErrorLogs(ctx, prepared); err != nil {
  409. log.Printf("[Ops] RecordErrorBatch failed: %v", err)
  410. return err
  411. }
  412. return nil
  413. }
  414. func (s *OpsService) prepareErrorLogInput(ctx context.Context, entry *OpsInsertErrorLogInput) (*OpsInsertErrorLogInput, bool, error) {
  415. if entry == nil {
  416. return nil, false, nil
  417. }
  418. if !s.IsMonitoringEnabled(ctx) {
  419. return nil, false, nil
  420. }
  421. if s.opsRepo == nil {
  422. return nil, false, nil
  423. }
  424. // Ensure timestamps are always populated.
  425. if entry.CreatedAt.IsZero() {
  426. entry.CreatedAt = time.Now()
  427. }
  428. // Ensure required fields exist (DB has NOT NULL constraints).
  429. entry.ErrorPhase = strings.TrimSpace(entry.ErrorPhase)
  430. entry.ErrorType = strings.TrimSpace(entry.ErrorType)
  431. if entry.ErrorPhase == "" {
  432. entry.ErrorPhase = "internal"
  433. }
  434. if entry.ErrorType == "" {
  435. entry.ErrorType = "api_error"
  436. }
  437. // Credential acquisition is a gateway/account-auth stage, not an inference
  438. // HTTP attempt. Enforce that ownership at the persistence boundary so an
  439. // earlier inference attempt cannot leak its status or text into top-level
  440. // auth fields even if a caller supplied stale single-value context.
  441. for i := len(entry.UpstreamErrors) - 1; i >= 0; i-- {
  442. last := entry.UpstreamErrors[i]
  443. if last == nil {
  444. continue
  445. }
  446. if last.Stage == string(GatewayFailureStageAccountAuth) {
  447. entry.ErrorPhase = string(GatewayFailureStageAccountAuth)
  448. entry.ErrorOwner = "provider"
  449. entry.ErrorSource = "gateway"
  450. code := 0
  451. entry.UpstreamStatusCode = &code
  452. entry.UpstreamErrorMessage = nil
  453. if message := strings.TrimSpace(last.Message); message != "" {
  454. entry.UpstreamErrorMessage = &message
  455. }
  456. entry.UpstreamErrorDetail = nil
  457. if detail := strings.TrimSpace(last.Detail); detail != "" {
  458. entry.UpstreamErrorDetail = &detail
  459. }
  460. }
  461. break
  462. }
  463. // Sanitize + truncate error_body to avoid storing sensitive data.
  464. if strings.TrimSpace(entry.ErrorBody) != "" {
  465. sanitized, _ := sanitizeErrorBodyForStorage(entry.ErrorBody, opsMaxStoredErrorBodyBytes)
  466. entry.ErrorBody = sanitized
  467. }
  468. // Sanitize upstream error context if provided by gateway services.
  469. if entry.UpstreamStatusCode != nil && *entry.UpstreamStatusCode <= 0 && entry.ErrorPhase != string(GatewayFailureStageAccountAuth) {
  470. entry.UpstreamStatusCode = nil
  471. }
  472. if entry.UpstreamErrorMessage != nil {
  473. msg := strings.TrimSpace(*entry.UpstreamErrorMessage)
  474. msg = sanitizeUpstreamErrorMessage(msg)
  475. msg = truncateString(msg, 2048)
  476. if strings.TrimSpace(msg) == "" {
  477. entry.UpstreamErrorMessage = nil
  478. } else {
  479. entry.UpstreamErrorMessage = &msg
  480. }
  481. }
  482. if entry.UpstreamErrorDetail != nil {
  483. detail := strings.TrimSpace(*entry.UpstreamErrorDetail)
  484. if detail == "" {
  485. entry.UpstreamErrorDetail = nil
  486. } else {
  487. sanitized, _ := sanitizeErrorBodyForStorage(detail, opsMaxStoredErrorBodyBytes)
  488. if strings.TrimSpace(sanitized) == "" {
  489. entry.UpstreamErrorDetail = nil
  490. } else {
  491. entry.UpstreamErrorDetail = &sanitized
  492. }
  493. }
  494. }
  495. if err := sanitizeOpsUpstreamErrors(entry); err != nil {
  496. return nil, false, err
  497. }
  498. return entry, true, nil
  499. }
  500. const (
  501. // opsUpstreamErrorsBodyWindow is how many of the newest attempts keep their
  502. // larger detail / upstream_response_body payloads while queued.
  503. opsUpstreamErrorsBodyWindow = 16
  504. // Attempts older than the body window keep scalar metadata only, with URL
  505. // and message trimmed harder than the newest attempts (2048).
  506. opsUpstreamErrorsOlderURLMaxLen = 512
  507. opsUpstreamErrorsOlderMessageMaxLen = 512
  508. // Hard bounds for the upstream_errors array of one queued entry. Newest
  509. // attempts win; the oldest retained attempt records the dropped count.
  510. opsUpstreamErrorsMaxEvents = 256
  511. opsUpstreamErrorsQueueMaxBytes = 512 * 1024
  512. )
  513. func sanitizeOpsUpstreamErrors(entry *OpsInsertErrorLogInput) error {
  514. if entry == nil || len(entry.UpstreamErrors) == 0 {
  515. return nil
  516. }
  517. events := make([]*OpsUpstreamErrorEvent, 0, len(entry.UpstreamErrors))
  518. for _, ev := range entry.UpstreamErrors {
  519. if ev != nil {
  520. events = append(events, ev)
  521. }
  522. }
  523. firstEventWithBody := len(events) - opsUpstreamErrorsBodyWindow
  524. if firstEventWithBody < 0 {
  525. firstEventWithBody = 0
  526. }
  527. sanitized := make([]*OpsUpstreamErrorEvent, 0, len(events))
  528. for i, ev := range events {
  529. out := *ev
  530. normalizeOpsUpstreamProxyAttribution(&out)
  531. // Only boundOpsUpstreamErrors may stamp this; never trust caller input.
  532. out.DroppedEarlierAttempts = 0
  533. keepBody := i >= firstEventWithBody
  534. urlMaxLen, messageMaxLen := 2048, 2048
  535. if !keepBody {
  536. urlMaxLen, messageMaxLen = opsUpstreamErrorsOlderURLMaxLen, opsUpstreamErrorsOlderMessageMaxLen
  537. }
  538. out.Platform = truncateString(strings.TrimSpace(out.Platform), 32)
  539. out.AccountName = truncateString(strings.TrimSpace(out.AccountName), 128)
  540. out.ProxyName = truncateString(strings.TrimSpace(out.ProxyName), 128)
  541. out.UpstreamRequestID = truncateString(strings.TrimSpace(out.UpstreamRequestID), 128)
  542. out.UpstreamURL = truncateString(strings.TrimSpace(out.UpstreamURL), urlMaxLen)
  543. if body := strings.TrimSpace(out.UpstreamResponseBody); keepBody && body != "" {
  544. out.UpstreamResponseBody, _ = sanitizeErrorBodyForStorage(body, OpsErrorLogQueueBodyMaxBytes)
  545. } else {
  546. out.UpstreamResponseBody = ""
  547. }
  548. out.Kind = truncateString(strings.TrimSpace(out.Kind), 64)
  549. out.Stage = truncateString(strings.TrimSpace(out.Stage), 64)
  550. out.Scope = truncateString(strings.TrimSpace(out.Scope), 64)
  551. out.Reason = truncateString(strings.TrimSpace(out.Reason), 128)
  552. if out.AccountID < 0 {
  553. out.AccountID = 0
  554. }
  555. if out.UpstreamStatusCode < 0 {
  556. out.UpstreamStatusCode = 0
  557. }
  558. if out.AtUnixMs < 0 {
  559. out.AtUnixMs = 0
  560. }
  561. msg := sanitizeUpstreamErrorMessage(strings.TrimSpace(out.Message))
  562. msg = truncateString(msg, messageMaxLen)
  563. out.Message = msg
  564. detail := strings.TrimSpace(out.Detail)
  565. // Drop fully-empty events (can happen if only status code was known).
  566. // Judged on the original detail so an older attempt whose detail is
  567. // cleared by the body window below is still retained.
  568. if out.UpstreamStatusCode == 0 && out.Message == "" && detail == "" {
  569. continue
  570. }
  571. if keepBody && detail != "" {
  572. // Keep upstream detail small while the event waits in the queue.
  573. sanitizedDetail, _ := sanitizeErrorBodyForStorage(detail, OpsErrorLogQueueBodyMaxBytes)
  574. out.Detail = sanitizedDetail
  575. } else {
  576. out.Detail = ""
  577. }
  578. evCopy := out
  579. sanitized = append(sanitized, &evCopy)
  580. }
  581. sanitized, _ = boundOpsUpstreamErrors(sanitized)
  582. entry.UpstreamErrorsJSON = marshalOpsUpstreamErrors(sanitized)
  583. entry.UpstreamErrors = nil
  584. return nil
  585. }
  586. // boundOpsUpstreamErrors enforces the per-entry event count and serialized
  587. // byte budget. It walks from the newest attempt backwards so the final,
  588. // outcome-deciding attempts are always retained (the newest event is kept even
  589. // if it alone exceeds the budget), and stamps the oldest retained event with
  590. // the number of dropped earlier attempts. Events must already be sanitized
  591. // copies owned by the caller.
  592. func boundOpsUpstreamErrors(events []*OpsUpstreamErrorEvent) ([]*OpsUpstreamErrorEvent, int) {
  593. if len(events) == 0 {
  594. return events, 0
  595. }
  596. keepFrom := len(events)
  597. budget := opsUpstreamErrorsQueueMaxBytes
  598. for i := len(events) - 1; i >= 0; i-- {
  599. if len(events)-i > opsUpstreamErrorsMaxEvents {
  600. break
  601. }
  602. raw, err := json.Marshal(events[i])
  603. size := 0
  604. if err == nil {
  605. size = len(raw) + 1 // trailing comma / bracket share
  606. }
  607. if i != len(events)-1 && size > budget {
  608. break
  609. }
  610. budget -= size
  611. keepFrom = i
  612. }
  613. kept := events[keepFrom:]
  614. dropped := keepFrom
  615. if dropped > 0 {
  616. kept[0].DroppedEarlierAttempts = dropped
  617. }
  618. return kept, dropped
  619. }
  620. func (s *OpsService) GetErrorLogs(ctx context.Context, filter *OpsErrorLogFilter) (*OpsErrorLogList, error) {
  621. if err := s.RequireMonitoringEnabled(ctx); err != nil {
  622. return nil, err
  623. }
  624. if s.opsRepo == nil {
  625. return &OpsErrorLogList{Errors: []*OpsErrorLog{}, Total: 0, Page: 1, PageSize: 20}, nil
  626. }
  627. result, err := s.opsRepo.ListErrorLogs(ctx, filter)
  628. if err != nil {
  629. log.Printf("[Ops] GetErrorLogs failed: %v", err)
  630. return nil, err
  631. }
  632. return result, nil
  633. }
  634. // ListUserErrorRequests 返回某个用户自己的错误请求(精简脱敏)。
  635. // 强制:仅当前用户、View=all(含业务限流/余额类)、排除 count_tokens 噪声。
  636. func (s *OpsService) ListUserErrorRequests(ctx context.Context, userID int64, filter *OpsErrorLogFilter) (*UserErrorRequestList, error) {
  637. if filter == nil {
  638. filter = &OpsErrorLogFilter{}
  639. }
  640. f := *filter // 拷贝快照,避免原地篡改调用方的 filter(slice 字段只读,浅拷贝足够)
  641. filter = &f
  642. uid := userID
  643. filter.UserID = &uid
  644. // APIKeyID 透传:保留 handler 传入的值。安全由 buildOpsErrorLogsWhere 的
  645. // "user_id = 自己 AND api_key_id = X" 双重约束保证——传入他人 key 只会得到空集,无泄露。
  646. filter.View = "all"
  647. filter.ExcludeCountTokens = true
  648. filter.ModelFuzzy = true // 用户端模型过滤走 ILIKE 模糊;管理端不设此字段,保持精确
  649. // 防御:用户端不接受这些 admin-only / 特殊维度
  650. filter.UserQuery = ""
  651. filter.Owner = ""
  652. filter.Source = ""
  653. // 清空 Phase 是防御:用户端一律改走 category→ErrorPhasesAny/ErrorTypesAny
  654. //(纯 ANY 过滤,不影响 status>=400 子句)。守卫豁免现在还需要
  655. // IncludeRecoveredUpstream(用户端永不设置),recovered upstream
  656. //(error_phase='upstream' 但 status<400,最终成功返回)记录对用户不可见——符合预期。
  657. filter.Phase = ""
  658. filter.IncludeRecoveredUpstream = false
  659. list, err := s.opsRepo.ListErrorLogs(ctx, filter)
  660. if err != nil {
  661. return nil, err
  662. }
  663. items := make([]*UserErrorRequest, 0, len(list.Errors))
  664. for _, e := range list.Errors {
  665. if r := ToUserErrorRequest(e); r != nil {
  666. items = append(items, r)
  667. }
  668. }
  669. return &UserErrorRequestList{
  670. Items: items,
  671. Total: list.Total,
  672. Page: list.Page,
  673. PageSize: list.PageSize,
  674. }, nil
  675. }
  676. func (s *OpsService) GetErrorLogByID(ctx context.Context, id int64) (*OpsErrorLogDetail, error) {
  677. if err := s.RequireMonitoringEnabled(ctx); err != nil {
  678. return nil, err
  679. }
  680. if s.opsRepo == nil {
  681. return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
  682. }
  683. detail, err := s.opsRepo.GetErrorLogByID(ctx, id)
  684. if err != nil {
  685. if errors.Is(err, sql.ErrNoRows) {
  686. return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
  687. }
  688. return nil, infraerrors.InternalServer("OPS_ERROR_LOAD_FAILED", "Failed to load ops error log").WithCause(err)
  689. }
  690. if detail != nil && strings.TrimSpace(detail.UpstreamErrors) != "" {
  691. if normalized, normalizeErr := normalizeOpsUpstreamErrorsJSON(detail.UpstreamErrors); normalizeErr == nil {
  692. detail.UpstreamErrors = normalized
  693. }
  694. }
  695. return detail, nil
  696. }
  697. // GetUserErrorRequestDetail 返回某用户自己某条错误请求的脱敏详情(含 error_body)。
  698. // 安全:强制按用户归属校验;非本人记录一律返回 NotFound(不泄露存在性)。
  699. func (s *OpsService) GetUserErrorRequestDetail(ctx context.Context, userID, id int64) (*UserErrorRequestDetail, error) {
  700. if s.opsRepo == nil {
  701. return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
  702. }
  703. if id <= 0 {
  704. return nil, infraerrors.BadRequest("OPS_ERROR_INVALID_ID", "invalid error id")
  705. }
  706. detail, err := s.opsRepo.GetErrorLogByID(ctx, id)
  707. if err != nil {
  708. if errors.Is(err, sql.ErrNoRows) {
  709. return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
  710. }
  711. return nil, infraerrors.InternalServer("OPS_ERROR_LOAD_FAILED", "Failed to load ops error log").WithCause(err)
  712. }
  713. // 归属只能由通过鉴权时写入的 user_id 确定。
  714. ownedDirectly := detail.UserID != nil && *detail.UserID == userID
  715. if !ownedDirectly {
  716. return nil, infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
  717. }
  718. return ToUserErrorRequestDetail(detail), nil
  719. }
  720. func (s *OpsService) UpdateErrorResolution(ctx context.Context, errorID int64, resolved bool, resolvedByUserID *int64) error {
  721. if err := s.RequireMonitoringEnabled(ctx); err != nil {
  722. return err
  723. }
  724. if s.opsRepo == nil {
  725. return infraerrors.ServiceUnavailable("OPS_REPO_UNAVAILABLE", "Ops repository not available")
  726. }
  727. if errorID <= 0 {
  728. return infraerrors.BadRequest("OPS_ERROR_INVALID_ID", "invalid error id")
  729. }
  730. // Best-effort ensure the error exists
  731. if _, err := s.opsRepo.GetErrorLogByID(ctx, errorID); err != nil {
  732. if errors.Is(err, sql.ErrNoRows) {
  733. return infraerrors.NotFound("OPS_ERROR_NOT_FOUND", "ops error log not found")
  734. }
  735. return infraerrors.InternalServer("OPS_ERROR_LOAD_FAILED", "Failed to load ops error log").WithCause(err)
  736. }
  737. return s.opsRepo.UpdateErrorResolution(ctx, errorID, resolved, resolvedByUserID, nil)
  738. }
  739. func sanitizeAndTrimJSONPayload(raw []byte, maxBytes int) (jsonString string, truncated bool, bytesLen int) {
  740. bytesLen = len(raw)
  741. if len(raw) == 0 {
  742. return "", false, 0
  743. }
  744. var decoded any
  745. if err := json.Unmarshal(raw, &decoded); err != nil {
  746. // If it is not valid JSON, fall back to the caller's non-JSON handling.
  747. return "", false, bytesLen
  748. }
  749. decoded = redactSensitiveJSON(decoded)
  750. encoded, err := json.Marshal(decoded)
  751. if err != nil {
  752. return "", false, bytesLen
  753. }
  754. if len(encoded) <= maxBytes {
  755. return string(encoded), false, bytesLen
  756. }
  757. // Trim conversation history to keep the most recent context.
  758. if root, ok := decoded.(map[string]any); ok {
  759. if trimmed, ok := trimConversationArrays(root, maxBytes); ok {
  760. encoded2, err2 := json.Marshal(trimmed)
  761. if err2 == nil && len(encoded2) <= maxBytes {
  762. return string(encoded2), true, bytesLen
  763. }
  764. // Fallthrough: keep shrinking.
  765. decoded = trimmed
  766. }
  767. essential := shrinkToEssentials(root)
  768. encoded3, err3 := json.Marshal(essential)
  769. if err3 == nil && len(encoded3) <= maxBytes {
  770. return string(encoded3), true, bytesLen
  771. }
  772. }
  773. // Last resort: keep JSON shape but drop big fields.
  774. // This avoids downstream code that expects certain top-level keys from crashing.
  775. if root, ok := decoded.(map[string]any); ok {
  776. placeholder := shallowCopyMap(root)
  777. placeholder["payload_truncated"] = true
  778. // Replace potentially huge arrays/strings, but keep the keys present.
  779. for _, k := range []string{"messages", "contents", "input", "prompt"} {
  780. if _, exists := placeholder[k]; exists {
  781. placeholder[k] = []any{}
  782. }
  783. }
  784. for _, k := range []string{"text"} {
  785. if _, exists := placeholder[k]; exists {
  786. placeholder[k] = ""
  787. }
  788. }
  789. encoded4, err4 := json.Marshal(placeholder)
  790. if err4 == nil {
  791. if len(encoded4) <= maxBytes {
  792. return string(encoded4), true, bytesLen
  793. }
  794. }
  795. }
  796. // Final fallback: minimal valid JSON.
  797. encoded4, err4 := json.Marshal(map[string]any{"payload_truncated": true})
  798. if err4 != nil {
  799. return "", true, bytesLen
  800. }
  801. return string(encoded4), true, bytesLen
  802. }
  803. func redactSensitiveJSON(v any) any {
  804. switch t := v.(type) {
  805. case map[string]any:
  806. out := make(map[string]any, len(t))
  807. for k, vv := range t {
  808. if isSensitiveKey(k) {
  809. out[k] = "[REDACTED]"
  810. continue
  811. }
  812. out[k] = redactSensitiveJSON(vv)
  813. }
  814. return out
  815. case []any:
  816. out := make([]any, 0, len(t))
  817. for _, vv := range t {
  818. out = append(out, redactSensitiveJSON(vv))
  819. }
  820. return out
  821. default:
  822. return v
  823. }
  824. }
  825. func isSensitiveKey(key string) bool {
  826. k := strings.ToLower(strings.TrimSpace(key))
  827. if k == "" {
  828. return false
  829. }
  830. // Token 计数 / 预算字段不是凭据,应保留用于排错。
  831. // 白名单保持尽量窄,避免误把真实敏感信息"反脱敏"。
  832. switch k {
  833. case "max_tokens",
  834. "max_output_tokens",
  835. "max_input_tokens",
  836. "max_completion_tokens",
  837. "max_tokens_to_sample",
  838. "budget_tokens",
  839. "prompt_tokens",
  840. "completion_tokens",
  841. "input_tokens",
  842. "output_tokens",
  843. "total_tokens",
  844. "token_count",
  845. "cache_creation_input_tokens",
  846. "cache_read_input_tokens":
  847. return false
  848. }
  849. // Exact matches (common credential fields).
  850. switch k {
  851. case "authorization",
  852. "proxy-authorization",
  853. "x-api-key",
  854. "api_key",
  855. "apikey",
  856. "access_token",
  857. "refresh_token",
  858. "id_token",
  859. "session_token",
  860. "token",
  861. "password",
  862. "passwd",
  863. "passphrase",
  864. "secret",
  865. "client_secret",
  866. "private_key",
  867. "jwt",
  868. "signature",
  869. "accesskeyid",
  870. "secretaccesskey":
  871. return true
  872. }
  873. // Suffix matches.
  874. for _, suffix := range []string{
  875. "_secret",
  876. "_token",
  877. "_id_token",
  878. "_session_token",
  879. "_password",
  880. "_passwd",
  881. "_passphrase",
  882. "_key",
  883. "secret_key",
  884. "private_key",
  885. } {
  886. if strings.HasSuffix(k, suffix) {
  887. return true
  888. }
  889. }
  890. // Substring matches (conservative, but errs on the side of privacy).
  891. for _, sub := range []string{
  892. "secret",
  893. "token",
  894. "password",
  895. "passwd",
  896. "passphrase",
  897. "privatekey",
  898. "private_key",
  899. "apikey",
  900. "api_key",
  901. "accesskeyid",
  902. "secretaccesskey",
  903. "bearer",
  904. "cookie",
  905. "credential",
  906. "session",
  907. "jwt",
  908. "signature",
  909. } {
  910. if strings.Contains(k, sub) {
  911. return true
  912. }
  913. }
  914. return false
  915. }
  916. func trimConversationArrays(root map[string]any, maxBytes int) (map[string]any, bool) {
  917. // Supported: anthropic/openai: messages; gemini: contents.
  918. if out, ok := trimArrayField(root, "messages", maxBytes); ok {
  919. return out, true
  920. }
  921. if out, ok := trimArrayField(root, "contents", maxBytes); ok {
  922. return out, true
  923. }
  924. return root, false
  925. }
  926. func trimArrayField(root map[string]any, field string, maxBytes int) (map[string]any, bool) {
  927. raw, ok := root[field]
  928. if !ok {
  929. return nil, false
  930. }
  931. arr, ok := raw.([]any)
  932. if !ok || len(arr) == 0 {
  933. return nil, false
  934. }
  935. // Keep at least the last message/content. Use binary search so we don't marshal O(n) times.
  936. // We are dropping from the *front* of the array (oldest context first).
  937. lo := 0
  938. hi := len(arr) - 1 // inclusive; hi ensures at least one item remains
  939. var best map[string]any
  940. found := false
  941. for lo <= hi {
  942. mid := (lo + hi) / 2
  943. candidateArr := arr[mid:]
  944. if len(candidateArr) == 0 {
  945. lo = mid + 1
  946. continue
  947. }
  948. next := shallowCopyMap(root)
  949. next[field] = candidateArr
  950. encoded, err := json.Marshal(next)
  951. if err != nil {
  952. // If marshal fails, try dropping more.
  953. lo = mid + 1
  954. continue
  955. }
  956. if len(encoded) <= maxBytes {
  957. best = next
  958. found = true
  959. // Try to keep more context by dropping fewer items.
  960. hi = mid - 1
  961. continue
  962. }
  963. // Need to drop more.
  964. lo = mid + 1
  965. }
  966. if found {
  967. return best, true
  968. }
  969. // Nothing fit (even with only one element); return the smallest slice and let the
  970. // caller fall back to shrinkToEssentials().
  971. next := shallowCopyMap(root)
  972. next[field] = arr[len(arr)-1:]
  973. return next, true
  974. }
  975. func shrinkToEssentials(root map[string]any) map[string]any {
  976. out := make(map[string]any)
  977. for _, key := range []string{
  978. "model",
  979. "stream",
  980. "max_tokens",
  981. "max_output_tokens",
  982. "max_input_tokens",
  983. "max_completion_tokens",
  984. "thinking",
  985. "temperature",
  986. "top_p",
  987. "top_k",
  988. } {
  989. if v, ok := root[key]; ok {
  990. out[key] = v
  991. }
  992. }
  993. // Keep only the last element of the conversation array.
  994. if v, ok := root["messages"]; ok {
  995. if arr, ok := v.([]any); ok && len(arr) > 0 {
  996. out["messages"] = []any{arr[len(arr)-1]}
  997. }
  998. }
  999. if v, ok := root["contents"]; ok {
  1000. if arr, ok := v.([]any); ok && len(arr) > 0 {
  1001. out["contents"] = []any{arr[len(arr)-1]}
  1002. }
  1003. }
  1004. return out
  1005. }
  1006. func shallowCopyMap(m map[string]any) map[string]any {
  1007. out := make(map[string]any, len(m))
  1008. for k, v := range m {
  1009. out[k] = v
  1010. }
  1011. return out
  1012. }
  1013. func sanitizeErrorBodyForStorage(raw string, maxBytes int) (sanitized string, truncated bool) {
  1014. raw = strings.TrimSpace(raw)
  1015. if raw == "" {
  1016. return "", false
  1017. }
  1018. // Prefer JSON-safe sanitization when possible.
  1019. if out, trunc, _ := sanitizeAndTrimJSONPayload([]byte(raw), maxBytes); out != "" {
  1020. return out, trunc
  1021. }
  1022. // Non-JSON: best-effort truncate.
  1023. if maxBytes > 0 && len(raw) > maxBytes {
  1024. return truncateString(raw, maxBytes), true
  1025. }
  1026. return raw, false
  1027. }