279 lines
4.8 KiB
Go
279 lines
4.8 KiB
Go
|
|
package auditlog
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
"go.uber.org/zap"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
defaultBatchSize = 64
|
||
|
|
defaultFlushInterval = time.Second
|
||
|
|
defaultFlushTimeout = 5 * time.Second
|
||
|
|
)
|
||
|
|
|
||
|
|
type Entry struct {
|
||
|
|
OperatorID int64
|
||
|
|
TenantID *int64
|
||
|
|
Module string
|
||
|
|
Action string
|
||
|
|
ResourceType *string
|
||
|
|
ResourceID *int64
|
||
|
|
RequestID *string
|
||
|
|
BeforeJSON []byte
|
||
|
|
AfterJSON []byte
|
||
|
|
Result *string
|
||
|
|
ErrorMessage *string
|
||
|
|
}
|
||
|
|
|
||
|
|
type AsyncWriter struct {
|
||
|
|
pool *pgxpool.Pool
|
||
|
|
logger *zap.Logger
|
||
|
|
batchSize int
|
||
|
|
flushInterval time.Duration
|
||
|
|
flushTimeout time.Duration
|
||
|
|
|
||
|
|
mu sync.Mutex
|
||
|
|
pending []Entry
|
||
|
|
readPos int
|
||
|
|
signal chan struct{}
|
||
|
|
closed bool
|
||
|
|
wg sync.WaitGroup
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewAsyncWriter(pool *pgxpool.Pool, logger *zap.Logger) *AsyncWriter {
|
||
|
|
writer := &AsyncWriter{
|
||
|
|
pool: pool,
|
||
|
|
logger: logger,
|
||
|
|
batchSize: defaultBatchSize,
|
||
|
|
flushInterval: defaultFlushInterval,
|
||
|
|
flushTimeout: defaultFlushTimeout,
|
||
|
|
signal: make(chan struct{}, 1),
|
||
|
|
}
|
||
|
|
writer.wg.Add(1)
|
||
|
|
go writer.run()
|
||
|
|
return writer
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *AsyncWriter) Log(entry Entry) bool {
|
||
|
|
if w == nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
w.mu.Lock()
|
||
|
|
if w.closed {
|
||
|
|
w.mu.Unlock()
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
w.pending = append(w.pending, entry)
|
||
|
|
w.mu.Unlock()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case w.signal <- struct{}{}:
|
||
|
|
default:
|
||
|
|
}
|
||
|
|
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *AsyncWriter) Close(ctx context.Context) error {
|
||
|
|
if w == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
w.mu.Lock()
|
||
|
|
if !w.closed {
|
||
|
|
w.closed = true
|
||
|
|
}
|
||
|
|
w.mu.Unlock()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case w.signal <- struct{}{}:
|
||
|
|
default:
|
||
|
|
}
|
||
|
|
|
||
|
|
done := make(chan struct{})
|
||
|
|
go func() {
|
||
|
|
defer close(done)
|
||
|
|
w.wg.Wait()
|
||
|
|
}()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case <-done:
|
||
|
|
return nil
|
||
|
|
case <-ctx.Done():
|
||
|
|
return ctx.Err()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *AsyncWriter) run() {
|
||
|
|
defer w.wg.Done()
|
||
|
|
|
||
|
|
ticker := time.NewTicker(w.flushInterval)
|
||
|
|
defer ticker.Stop()
|
||
|
|
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-w.signal:
|
||
|
|
case <-ticker.C:
|
||
|
|
}
|
||
|
|
|
||
|
|
for {
|
||
|
|
batch, closed := w.nextBatch()
|
||
|
|
if len(batch) == 0 {
|
||
|
|
if closed {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if err := w.flush(batch); err != nil {
|
||
|
|
w.logger.Warn("audit log batch flush failed; keeping backlog for retry",
|
||
|
|
zap.Int("count", len(batch)),
|
||
|
|
zap.Error(err),
|
||
|
|
)
|
||
|
|
w.requeueFront(batch)
|
||
|
|
time.Sleep(500 * time.Millisecond)
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *AsyncWriter) flush(entries []Entry) error {
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), w.flushTimeout)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
query, args := buildInsert(entries)
|
||
|
|
if _, err := w.pool.Exec(ctx, query, args...); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *AsyncWriter) nextBatch() ([]Entry, bool) {
|
||
|
|
w.mu.Lock()
|
||
|
|
defer w.mu.Unlock()
|
||
|
|
|
||
|
|
available := len(w.pending) - w.readPos
|
||
|
|
if available == 0 {
|
||
|
|
if w.readPos > 0 {
|
||
|
|
w.pending = nil
|
||
|
|
w.readPos = 0
|
||
|
|
}
|
||
|
|
return nil, w.closed
|
||
|
|
}
|
||
|
|
|
||
|
|
size := w.batchSize
|
||
|
|
if available < size {
|
||
|
|
size = available
|
||
|
|
}
|
||
|
|
|
||
|
|
batch := append([]Entry(nil), w.pending[w.readPos:w.readPos+size]...)
|
||
|
|
w.readPos += size
|
||
|
|
|
||
|
|
if w.readPos == len(w.pending) {
|
||
|
|
w.pending = nil
|
||
|
|
w.readPos = 0
|
||
|
|
} else if w.readPos >= len(w.pending)/2 {
|
||
|
|
remaining := append([]Entry(nil), w.pending[w.readPos:]...)
|
||
|
|
w.pending = remaining
|
||
|
|
w.readPos = 0
|
||
|
|
}
|
||
|
|
|
||
|
|
return batch, w.closed
|
||
|
|
}
|
||
|
|
|
||
|
|
func (w *AsyncWriter) requeueFront(entries []Entry) {
|
||
|
|
w.mu.Lock()
|
||
|
|
defer w.mu.Unlock()
|
||
|
|
|
||
|
|
if len(entries) == 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if w.readPos > 0 {
|
||
|
|
remaining := append([]Entry(nil), w.pending[w.readPos:]...)
|
||
|
|
w.pending = remaining
|
||
|
|
w.readPos = 0
|
||
|
|
}
|
||
|
|
|
||
|
|
w.pending = append(append([]Entry(nil), entries...), w.pending...)
|
||
|
|
}
|
||
|
|
|
||
|
|
func buildInsert(entries []Entry) (string, []interface{}) {
|
||
|
|
var builder strings.Builder
|
||
|
|
builder.WriteString(`INSERT INTO audit_logs (
|
||
|
|
operator_id,
|
||
|
|
tenant_id,
|
||
|
|
module,
|
||
|
|
action,
|
||
|
|
resource_type,
|
||
|
|
resource_id,
|
||
|
|
request_id,
|
||
|
|
before_json,
|
||
|
|
after_json,
|
||
|
|
result,
|
||
|
|
error_message
|
||
|
|
) VALUES `)
|
||
|
|
|
||
|
|
args := make([]interface{}, 0, len(entries)*11)
|
||
|
|
placeholder := 1
|
||
|
|
|
||
|
|
for i, entry := range entries {
|
||
|
|
if i > 0 {
|
||
|
|
builder.WriteString(",")
|
||
|
|
}
|
||
|
|
builder.WriteString("(")
|
||
|
|
for col := 0; col < 11; col++ {
|
||
|
|
if col > 0 {
|
||
|
|
builder.WriteString(",")
|
||
|
|
}
|
||
|
|
builder.WriteString(fmt.Sprintf("$%d", placeholder))
|
||
|
|
placeholder++
|
||
|
|
}
|
||
|
|
builder.WriteString(")")
|
||
|
|
|
||
|
|
args = append(args,
|
||
|
|
entry.OperatorID,
|
||
|
|
derefInt64(entry.TenantID),
|
||
|
|
entry.Module,
|
||
|
|
entry.Action,
|
||
|
|
derefString(entry.ResourceType),
|
||
|
|
derefInt64(entry.ResourceID),
|
||
|
|
derefString(entry.RequestID),
|
||
|
|
nilIfEmptyBytes(entry.BeforeJSON),
|
||
|
|
nilIfEmptyBytes(entry.AfterJSON),
|
||
|
|
derefString(entry.Result),
|
||
|
|
derefString(entry.ErrorMessage),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return builder.String(), args
|
||
|
|
}
|
||
|
|
|
||
|
|
func derefString(value *string) interface{} {
|
||
|
|
if value == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return *value
|
||
|
|
}
|
||
|
|
|
||
|
|
func derefInt64(value *int64) interface{} {
|
||
|
|
if value == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return *value
|
||
|
|
}
|
||
|
|
|
||
|
|
func nilIfEmptyBytes(value []byte) interface{} {
|
||
|
|
if len(value) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|