ops_system_log_sink_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "strings"
  8. "sync/atomic"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. )
  13. func TestOpsSystemLogSink_ShouldIndex(t *testing.T) {
  14. sink := &OpsSystemLogSink{}
  15. cases := []struct {
  16. name string
  17. event *logger.LogEvent
  18. want bool
  19. }{
  20. {
  21. name: "warn level",
  22. event: &logger.LogEvent{Level: "warn", Component: "app"},
  23. want: true,
  24. },
  25. {
  26. name: "error level",
  27. event: &logger.LogEvent{Level: "error", Component: "app"},
  28. want: true,
  29. },
  30. {
  31. name: "access component disabled by default",
  32. event: &logger.LogEvent{Level: "info", Component: "http.access"},
  33. want: false,
  34. },
  35. {
  36. name: "rejected access excluded from database sink",
  37. event: &logger.LogEvent{
  38. Level: "info",
  39. Component: "http.access",
  40. Fields: map[string]any{logger.OpsSystemLogSkipField: true},
  41. },
  42. want: false,
  43. },
  44. {
  45. name: "access component from fields disabled by default",
  46. event: &logger.LogEvent{
  47. Level: "info",
  48. Component: "",
  49. Fields: map[string]any{"component": "http.access"},
  50. },
  51. want: false,
  52. },
  53. {
  54. name: "audit component",
  55. event: &logger.LogEvent{Level: "info", Component: "audit.log_config_change"},
  56. want: true,
  57. },
  58. {
  59. name: "audit component from fields (real zap path)",
  60. event: &logger.LogEvent{
  61. Level: "info",
  62. Component: "",
  63. Fields: map[string]any{"component": "audit.log_config_change"},
  64. },
  65. want: true,
  66. },
  67. {
  68. name: "plain info",
  69. event: &logger.LogEvent{Level: "info", Component: "app"},
  70. want: false,
  71. },
  72. }
  73. for _, tc := range cases {
  74. if got := sink.shouldIndex(tc.event); got != tc.want {
  75. t.Fatalf("%s: shouldIndex()=%v, want %v", tc.name, got, tc.want)
  76. }
  77. }
  78. }
  79. func TestOpsSystemLogSink_ShouldIndexAccessLogsOnlyWhenEnabled(t *testing.T) {
  80. sink := &OpsSystemLogSink{}
  81. sink.SetPersistAccessLogs(true)
  82. if !sink.shouldIndex(&logger.LogEvent{Level: "info", Component: "http.access"}) {
  83. t.Fatal("access log should be indexed after explicit opt-in")
  84. }
  85. if !sink.shouldIndex(&logger.LogEvent{
  86. Level: "info",
  87. Fields: map[string]any{"component": "http.access"},
  88. }) {
  89. t.Fatal("field-based access log should be indexed after explicit opt-in")
  90. }
  91. if sink.shouldIndex(&logger.LogEvent{
  92. Level: "info",
  93. Component: "http.access",
  94. Fields: map[string]any{logger.OpsSystemLogSkipField: true},
  95. }) {
  96. t.Fatal("explicit skip marker must override access-log persistence")
  97. }
  98. }
  99. func TestOpsSystemLogSink_WriteLogEvent_ShouldDropWhenQueueFull(t *testing.T) {
  100. sink := &OpsSystemLogSink{
  101. queue: make(chan *logger.LogEvent, 1),
  102. }
  103. sink.WriteLogEvent(&logger.LogEvent{Level: "warn", Component: "app"})
  104. sink.WriteLogEvent(&logger.LogEvent{Level: "warn", Component: "app"})
  105. if got := len(sink.queue); got != 1 {
  106. t.Fatalf("queue len = %d, want 1", got)
  107. }
  108. if dropped := atomic.LoadUint64(&sink.droppedCount); dropped != 1 {
  109. t.Fatalf("droppedCount = %d, want 1", dropped)
  110. }
  111. }
  112. func TestOpsSystemLogSink_Health(t *testing.T) {
  113. sink := &OpsSystemLogSink{
  114. queue: make(chan *logger.LogEvent, 10),
  115. }
  116. sink.lastError.Store("db timeout")
  117. atomic.StoreUint64(&sink.droppedCount, 3)
  118. atomic.StoreUint64(&sink.writeFailed, 2)
  119. atomic.StoreUint64(&sink.writtenCount, 5)
  120. atomic.StoreUint64(&sink.totalDelayNs, uint64(5000000)) // 5ms total -> avg 1ms
  121. sink.queue <- &logger.LogEvent{Level: "warn", Component: "app"}
  122. sink.queue <- &logger.LogEvent{Level: "warn", Component: "app"}
  123. health := sink.Health()
  124. if health.QueueDepth != 2 {
  125. t.Fatalf("queue depth = %d, want 2", health.QueueDepth)
  126. }
  127. if health.QueueCapacity != 10 {
  128. t.Fatalf("queue capacity = %d, want 10", health.QueueCapacity)
  129. }
  130. if health.DroppedCount != 3 {
  131. t.Fatalf("dropped = %d, want 3", health.DroppedCount)
  132. }
  133. if health.WriteFailed != 2 {
  134. t.Fatalf("write failed = %d, want 2", health.WriteFailed)
  135. }
  136. if health.WrittenCount != 5 {
  137. t.Fatalf("written = %d, want 5", health.WrittenCount)
  138. }
  139. if health.AvgWriteDelayMs != 1 {
  140. t.Fatalf("avg delay ms = %d, want 1", health.AvgWriteDelayMs)
  141. }
  142. if health.LastError != "db timeout" {
  143. t.Fatalf("last error = %q, want db timeout", health.LastError)
  144. }
  145. }
  146. func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) {
  147. done := make(chan struct{}, 1)
  148. var captured []*OpsInsertSystemLogInput
  149. repo := &opsRepoMock{
  150. BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) {
  151. captured = append(captured, inputs...)
  152. select {
  153. case done <- struct{}{}:
  154. default:
  155. }
  156. return int64(len(inputs)), nil
  157. },
  158. }
  159. sink := NewOpsSystemLogSink(repo)
  160. sink.host = "api-node-1"
  161. sink.batchSize = 1
  162. sink.flushInterval = 10 * time.Millisecond
  163. sink.Start()
  164. defer sink.Stop()
  165. sink.WriteLogEvent(&logger.LogEvent{
  166. Time: time.Now().UTC(),
  167. Level: "warn",
  168. Component: "http.access",
  169. Message: `authorization="Bearer sk-test-123"`,
  170. Fields: map[string]any{
  171. "component": "http.access",
  172. "request_id": "req-1",
  173. "client_request_id": "creq-1",
  174. "user_id": "12",
  175. "api_key_id": int64(56),
  176. "account_id": json.Number("34"),
  177. "platform": "openai",
  178. "model": "gpt-5",
  179. },
  180. })
  181. select {
  182. case <-done:
  183. case <-time.After(2 * time.Second):
  184. t.Fatalf("timeout waiting for sink flush")
  185. }
  186. if len(captured) != 1 {
  187. t.Fatalf("captured len = %d, want 1", len(captured))
  188. }
  189. item := captured[0]
  190. if item.Host != "api-node-1" {
  191. t.Fatalf("host = %q, want api-node-1", item.Host)
  192. }
  193. if item.RequestID != "req-1" || item.ClientRequestID != "creq-1" {
  194. t.Fatalf("unexpected request ids: %+v", item)
  195. }
  196. if item.UserID == nil || *item.UserID != 12 {
  197. t.Fatalf("unexpected user_id: %+v", item.UserID)
  198. }
  199. if item.APIKeyID == nil || *item.APIKeyID != 56 {
  200. t.Fatalf("unexpected api_key_id: %+v", item.APIKeyID)
  201. }
  202. if item.AccountID == nil || *item.AccountID != 34 {
  203. t.Fatalf("unexpected account_id: %+v", item.AccountID)
  204. }
  205. if strings.TrimSpace(item.Message) == "" {
  206. t.Fatalf("message should not be empty")
  207. }
  208. // writtenCount is incremented after BatchInsertSystemLogsFn returns,
  209. // so poll briefly to avoid a race between the done signal and the atomic add.
  210. deadline := time.Now().Add(time.Second)
  211. for time.Now().Before(deadline) {
  212. if sink.Health().WrittenCount > 0 {
  213. break
  214. }
  215. time.Sleep(time.Millisecond)
  216. }
  217. health := sink.Health()
  218. if health.WrittenCount == 0 {
  219. t.Fatalf("written_count should be >0")
  220. }
  221. }
  222. func TestOpsSystemLogSink_FlushFailureUpdatesHealth(t *testing.T) {
  223. repo := &opsRepoMock{
  224. BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) {
  225. return 0, errors.New("db unavailable")
  226. },
  227. }
  228. sink := NewOpsSystemLogSink(repo)
  229. sink.batchSize = 1
  230. sink.flushInterval = 10 * time.Millisecond
  231. sink.Start()
  232. defer sink.Stop()
  233. sink.WriteLogEvent(&logger.LogEvent{
  234. Time: time.Now().UTC(),
  235. Level: "warn",
  236. Component: "app",
  237. Message: "boom",
  238. Fields: map[string]any{},
  239. })
  240. deadline := time.Now().Add(2 * time.Second)
  241. for time.Now().Before(deadline) {
  242. health := sink.Health()
  243. if health.WriteFailed > 0 {
  244. if !strings.Contains(health.LastError, "db unavailable") {
  245. t.Fatalf("unexpected last error: %s", health.LastError)
  246. }
  247. return
  248. }
  249. time.Sleep(20 * time.Millisecond)
  250. }
  251. t.Fatalf("write_failed_count not updated")
  252. }
  253. func TestOpsSystemLogSink_StopFlushUsesActiveContextAndDrainsQueue(t *testing.T) {
  254. var inserted int64
  255. var canceledCtxCalls int64
  256. repo := &opsRepoMock{
  257. BatchInsertSystemLogsFn: func(ctx context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) {
  258. if err := ctx.Err(); err != nil {
  259. atomic.AddInt64(&canceledCtxCalls, 1)
  260. return 0, err
  261. }
  262. atomic.AddInt64(&inserted, int64(len(inputs)))
  263. return int64(len(inputs)), nil
  264. },
  265. }
  266. sink := NewOpsSystemLogSink(repo)
  267. sink.batchSize = 200
  268. sink.flushInterval = time.Hour
  269. sink.Start()
  270. sink.WriteLogEvent(&logger.LogEvent{
  271. Time: time.Now().UTC(),
  272. Level: "warn",
  273. Component: "app",
  274. Message: "pending-on-shutdown",
  275. Fields: map[string]any{"component": "http.access"},
  276. })
  277. sink.Stop()
  278. if got := atomic.LoadInt64(&inserted); got != 1 {
  279. t.Fatalf("inserted = %d, want 1", got)
  280. }
  281. if got := atomic.LoadInt64(&canceledCtxCalls); got != 0 {
  282. t.Fatalf("canceled ctx calls = %d, want 0", got)
  283. }
  284. health := sink.Health()
  285. if health.WrittenCount != 1 {
  286. t.Fatalf("written_count = %d, want 1", health.WrittenCount)
  287. }
  288. }
  289. type stringerValue string
  290. func (s stringerValue) String() string { return string(s) }
  291. func TestOpsSystemLogSink_HelperFunctions(t *testing.T) {
  292. src := map[string]any{"a": 1}
  293. cloned := copyMap(src)
  294. src["a"] = 2
  295. v, ok := cloned["a"].(int)
  296. if !ok || v != 1 {
  297. t.Fatalf("copyMap should create copy")
  298. }
  299. if got := asString(stringerValue(" hello ")); got != "hello" {
  300. t.Fatalf("asString stringer = %q", got)
  301. }
  302. if got := asString(fmt.Errorf("x")); got != "" {
  303. t.Fatalf("asString error should be empty, got %q", got)
  304. }
  305. if got := asString(123); got != "" {
  306. t.Fatalf("asString non-string should be empty, got %q", got)
  307. }
  308. cases := []struct {
  309. in any
  310. want int64
  311. ok bool
  312. }{
  313. {in: 5, want: 5, ok: true},
  314. {in: int64(6), want: 6, ok: true},
  315. {in: float64(7), want: 7, ok: true},
  316. {in: json.Number("8"), want: 8, ok: true},
  317. {in: "9", want: 9, ok: true},
  318. {in: "0", ok: false},
  319. {in: -1, ok: false},
  320. {in: "abc", ok: false},
  321. }
  322. for _, tc := range cases {
  323. got := asInt64Ptr(tc.in)
  324. if tc.ok {
  325. if got == nil || *got != tc.want {
  326. t.Fatalf("asInt64Ptr(%v) = %+v, want %d", tc.in, got, tc.want)
  327. }
  328. } else if got != nil {
  329. t.Fatalf("asInt64Ptr(%v) should be nil, got %d", tc.in, *got)
  330. }
  331. }
  332. }
  333. func TestNormalizeSystemLogHost(t *testing.T) {
  334. if got := normalizeSystemLogHost(" api-node-1 ", nil); got != "api-node-1" {
  335. t.Fatalf("trimmed host = %q, want api-node-1", got)
  336. }
  337. if got := normalizeSystemLogHost("", nil); got != "unknown" {
  338. t.Fatalf("empty host = %q, want unknown", got)
  339. }
  340. if got := normalizeSystemLogHost("api-node-1", errors.New("hostname unavailable")); got != "unknown" {
  341. t.Fatalf("errored host = %q, want unknown", got)
  342. }
  343. longHost := strings.Repeat("节", maxSystemLogHostLength+1)
  344. got := normalizeSystemLogHost(longHost, nil)
  345. if runeCount := len([]rune(got)); runeCount != maxSystemLogHostLength {
  346. t.Fatalf("truncated host rune count = %d, want %d", runeCount, maxSystemLogHostLength)
  347. }
  348. }