feat(log): 落地统一日志底座与系统日志运维能力
This commit is contained in:
254
backend/internal/service/ops_system_log_sink_test.go
Normal file
254
backend/internal/service/ops_system_log_sink_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
func TestOpsSystemLogSink_ShouldIndex(t *testing.T) {
|
||||
sink := &OpsSystemLogSink{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
event *logger.LogEvent
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "warn level",
|
||||
event: &logger.LogEvent{Level: "warn", Component: "app"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "error level",
|
||||
event: &logger.LogEvent{Level: "error", Component: "app"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "access component",
|
||||
event: &logger.LogEvent{Level: "info", Component: "http.access"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "audit component",
|
||||
event: &logger.LogEvent{Level: "info", Component: "audit.log_config_change"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "plain info",
|
||||
event: &logger.LogEvent{Level: "info", Component: "app"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := sink.shouldIndex(tc.event); got != tc.want {
|
||||
t.Fatalf("%s: shouldIndex()=%v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogSink_WriteLogEvent_ShouldDropWhenQueueFull(t *testing.T) {
|
||||
sink := &OpsSystemLogSink{
|
||||
queue: make(chan *logger.LogEvent, 1),
|
||||
}
|
||||
|
||||
sink.WriteLogEvent(&logger.LogEvent{Level: "warn", Component: "app"})
|
||||
sink.WriteLogEvent(&logger.LogEvent{Level: "warn", Component: "app"})
|
||||
|
||||
if got := len(sink.queue); got != 1 {
|
||||
t.Fatalf("queue len = %d, want 1", got)
|
||||
}
|
||||
if dropped := atomic.LoadUint64(&sink.droppedCount); dropped != 1 {
|
||||
t.Fatalf("droppedCount = %d, want 1", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogSink_Health(t *testing.T) {
|
||||
sink := &OpsSystemLogSink{
|
||||
queue: make(chan *logger.LogEvent, 10),
|
||||
}
|
||||
sink.lastError.Store("db timeout")
|
||||
atomic.StoreUint64(&sink.droppedCount, 3)
|
||||
atomic.StoreUint64(&sink.writeFailed, 2)
|
||||
atomic.StoreUint64(&sink.writtenCount, 5)
|
||||
atomic.StoreUint64(&sink.totalDelayNs, uint64(5000000)) // 5ms total -> avg 1ms
|
||||
sink.queue <- &logger.LogEvent{Level: "warn", Component: "app"}
|
||||
sink.queue <- &logger.LogEvent{Level: "warn", Component: "app"}
|
||||
|
||||
health := sink.Health()
|
||||
if health.QueueDepth != 2 {
|
||||
t.Fatalf("queue depth = %d, want 2", health.QueueDepth)
|
||||
}
|
||||
if health.QueueCapacity != 10 {
|
||||
t.Fatalf("queue capacity = %d, want 10", health.QueueCapacity)
|
||||
}
|
||||
if health.DroppedCount != 3 {
|
||||
t.Fatalf("dropped = %d, want 3", health.DroppedCount)
|
||||
}
|
||||
if health.WriteFailed != 2 {
|
||||
t.Fatalf("write failed = %d, want 2", health.WriteFailed)
|
||||
}
|
||||
if health.WrittenCount != 5 {
|
||||
t.Fatalf("written = %d, want 5", health.WrittenCount)
|
||||
}
|
||||
if health.AvgWriteDelayMs != 1 {
|
||||
t.Fatalf("avg delay ms = %d, want 1", health.AvgWriteDelayMs)
|
||||
}
|
||||
if health.LastError != "db timeout" {
|
||||
t.Fatalf("last error = %q, want db timeout", health.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogSink_StartStopAndFlushSuccess(t *testing.T) {
|
||||
done := make(chan struct{}, 1)
|
||||
var captured []*OpsInsertSystemLogInput
|
||||
repo := &opsRepoMock{
|
||||
BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) {
|
||||
captured = append(captured, inputs...)
|
||||
select {
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return int64(len(inputs)), nil
|
||||
},
|
||||
}
|
||||
|
||||
sink := NewOpsSystemLogSink(repo)
|
||||
sink.batchSize = 1
|
||||
sink.flushInterval = 10 * time.Millisecond
|
||||
sink.Start()
|
||||
defer sink.Stop()
|
||||
|
||||
sink.WriteLogEvent(&logger.LogEvent{
|
||||
Time: time.Now().UTC(),
|
||||
Level: "warn",
|
||||
Component: "http.access",
|
||||
Message: `authorization="Bearer sk-test-123"`,
|
||||
Fields: map[string]any{
|
||||
"component": "http.access",
|
||||
"request_id": "req-1",
|
||||
"client_request_id": "creq-1",
|
||||
"user_id": "12",
|
||||
"account_id": json.Number("34"),
|
||||
"platform": "openai",
|
||||
"model": "gpt-5",
|
||||
},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for sink flush")
|
||||
}
|
||||
|
||||
if len(captured) != 1 {
|
||||
t.Fatalf("captured len = %d, want 1", len(captured))
|
||||
}
|
||||
item := captured[0]
|
||||
if item.RequestID != "req-1" || item.ClientRequestID != "creq-1" {
|
||||
t.Fatalf("unexpected request ids: %+v", item)
|
||||
}
|
||||
if item.UserID == nil || *item.UserID != 12 {
|
||||
t.Fatalf("unexpected user_id: %+v", item.UserID)
|
||||
}
|
||||
if item.AccountID == nil || *item.AccountID != 34 {
|
||||
t.Fatalf("unexpected account_id: %+v", item.AccountID)
|
||||
}
|
||||
if strings.TrimSpace(item.Message) == "" {
|
||||
t.Fatalf("message should not be empty")
|
||||
}
|
||||
health := sink.Health()
|
||||
if health.WrittenCount == 0 {
|
||||
t.Fatalf("written_count should be >0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogSink_FlushFailureUpdatesHealth(t *testing.T) {
|
||||
repo := &opsRepoMock{
|
||||
BatchInsertSystemLogsFn: func(_ context.Context, inputs []*OpsInsertSystemLogInput) (int64, error) {
|
||||
return 0, errors.New("db unavailable")
|
||||
},
|
||||
}
|
||||
sink := NewOpsSystemLogSink(repo)
|
||||
sink.batchSize = 1
|
||||
sink.flushInterval = 10 * time.Millisecond
|
||||
sink.Start()
|
||||
defer sink.Stop()
|
||||
|
||||
sink.WriteLogEvent(&logger.LogEvent{
|
||||
Time: time.Now().UTC(),
|
||||
Level: "warn",
|
||||
Component: "app",
|
||||
Message: "boom",
|
||||
Fields: map[string]any{},
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
health := sink.Health()
|
||||
if health.WriteFailed > 0 {
|
||||
if !strings.Contains(health.LastError, "db unavailable") {
|
||||
t.Fatalf("unexpected last error: %s", health.LastError)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("write_failed_count not updated")
|
||||
}
|
||||
|
||||
type stringerValue string
|
||||
|
||||
func (s stringerValue) String() string { return string(s) }
|
||||
|
||||
func TestOpsSystemLogSink_HelperFunctions(t *testing.T) {
|
||||
src := map[string]any{"a": 1}
|
||||
cloned := copyMap(src)
|
||||
src["a"] = 2
|
||||
v, ok := cloned["a"].(int)
|
||||
if !ok || v != 1 {
|
||||
t.Fatalf("copyMap should create copy")
|
||||
}
|
||||
if got := asString(stringerValue(" hello ")); got != "hello" {
|
||||
t.Fatalf("asString stringer = %q", got)
|
||||
}
|
||||
if got := asString(fmt.Errorf("x")); got != "" {
|
||||
t.Fatalf("asString error should be empty, got %q", got)
|
||||
}
|
||||
if got := asString(123); got != "" {
|
||||
t.Fatalf("asString non-string should be empty, got %q", got)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
in any
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{in: 5, want: 5, ok: true},
|
||||
{in: int64(6), want: 6, ok: true},
|
||||
{in: float64(7), want: 7, ok: true},
|
||||
{in: json.Number("8"), want: 8, ok: true},
|
||||
{in: "9", want: 9, ok: true},
|
||||
{in: "0", ok: false},
|
||||
{in: -1, ok: false},
|
||||
{in: "abc", ok: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := asInt64Ptr(tc.in)
|
||||
if tc.ok {
|
||||
if got == nil || *got != tc.want {
|
||||
t.Fatalf("asInt64Ptr(%v) = %+v, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
} else if got != nil {
|
||||
t.Fatalf("asInt64Ptr(%v) should be nil, got %d", tc.in, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user