Files
NianAIGC/backend/internal/logging/service.go

319 lines
8.3 KiB
Go

package logging
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
type Level string
const (
Info Level = "info"
Warning Level = "warning"
Error Level = "error"
)
type Entry struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
Level Level `json:"level"`
Source string `json:"source"`
Message string `json:"message"`
Status int `json:"status,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Stack string `json:"stack,omitempty"`
Details any `json:"details,omitempty"`
}
type Input struct {
Level Level
Source string
Message string
Error error
Status int
Method string
Path string
Stack string
Details any
}
type Filters struct {
Level, Q, Source string
Limit int
}
type Service struct {
mu sync.Mutex
path string
maxBytes int64
now func() time.Time
newID func() string
}
func New(path string, maxBytes int64, now func() time.Time, newID func() string) *Service {
if maxBytes <= 0 {
maxBytes = 5 * 1024 * 1024
}
if now == nil {
now = time.Now
}
if newID == nil {
newID = randomID
}
return &Service{path: path, maxBytes: maxBytes, now: now, newID: newID}
}
func (s *Service) Append(ctx context.Context, input Input) (Entry, error) {
if err := ctx.Err(); err != nil {
return Entry{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
level := input.Level
if !validLevel(level) {
if input.Status >= 500 {
level = Error
} else {
level = Info
}
}
message := input.Message
if message == "" && input.Error != nil {
message = input.Error.Error()
}
if message == "" {
message = "Unknown log event"
}
stack := input.Stack
if stack == "" && input.Error != nil {
stack = input.Error.Error()
}
entry := Entry{ID: "log_" + s.newID(), CreatedAt: s.now().UTC().Format(time.RFC3339Nano), Level: level, Source: sanitizeText(defaultString(input.Source, "server")), Message: sanitizeText(message), Status: input.Status, Method: sanitizeText(input.Method), Path: sanitizeText(input.Path), Stack: sanitizeText(stack), Details: sanitize(input.Details, 0, map[visit]bool{})}
line, err := json.Marshal(entry)
if err != nil {
return Entry{}, err
}
if err = os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return Entry{}, err
}
if info, statErr := os.Stat(s.path); statErr == nil && info.Size() >= s.maxBytes {
if err = os.Rename(s.path, s.path+".1"); err != nil {
return Entry{}, err
}
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return Entry{}, statErr
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return Entry{}, err
}
_, writeErr := file.Write(append(line, '\n'))
closeErr := file.Close()
if writeErr != nil {
return Entry{}, writeErr
}
if closeErr != nil {
return Entry{}, closeErr
}
return entry, nil
}
func (s *Service) List(ctx context.Context, filters Filters) ([]Entry, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return []Entry{}, nil
}
if err != nil {
return nil, err
}
limit := filters.Limit
if limit == 0 {
limit = 100
}
if limit < 1 {
limit = 1
}
if limit > 500 {
limit = 500
}
q := strings.ToLower(strings.TrimSpace(filters.Q))
source := strings.ToLower(strings.TrimSpace(filters.Source))
level := strings.TrimSpace(filters.Level)
lines := strings.Split(string(data), "\n")
result := make([]Entry, 0, limit)
for i := len(lines) - 1; i >= 0 && len(result) < limit; i-- {
if strings.TrimSpace(lines[i]) == "" {
continue
}
var entry Entry
if json.Unmarshal([]byte(lines[i]), &entry) != nil || entry.ID == "" || entry.CreatedAt == "" || entry.Source == "" || entry.Message == "" || !validLevel(entry.Level) {
continue
}
if level != "" && level != "all" && string(entry.Level) != level {
continue
}
if source != "" && !strings.Contains(strings.ToLower(entry.Source), source) {
continue
}
if q != "" && !strings.Contains(strings.ToLower(search(entry)), q) {
continue
}
result = append(result, entry)
}
return result, nil
}
func (s *Service) Clear(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
return os.WriteFile(s.path, []byte{}, 0o600)
}
const maxText = 20000
var sensitiveKey = regexp.MustCompile(`(?i)api[_-]?key|access[_-]?key|secret|token|password|authorization|credential`)
var authorizationText = regexp.MustCompile(`(?i)(authorization\s*[:=]\s*)[^\s,;}]+`)
var authorizationBearerText = regexp.MustCompile(`(?i)(authorization\s*[:=]\s*bearer\s+)[^\s,;}]+`)
var bearerText = regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+`)
var sensitiveText = regexp.MustCompile(`(?i)((?:api[_-]?key|access[_-]?key|secret|token|password|client[_-]?secret)\s*[:=]\s*)[^\s,;}]+`)
var jwtText = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`)
func sanitizeText(input string) string {
input = truncate(input)
input = authorizationBearerText.ReplaceAllString(input, "${1}[redacted]")
input = authorizationText.ReplaceAllString(input, "${1}[redacted]")
input = bearerText.ReplaceAllString(input, "${1}[redacted]")
input = sensitiveText.ReplaceAllString(input, "${1}[redacted]")
return jwtText.ReplaceAllString(input, "[jwt-redacted]")
}
func truncate(input string) string {
runes := []rune(input)
if len(runes) <= maxText {
return input
}
return string(runes[:maxText]) + "...[truncated]"
}
type visit struct {
kind reflect.Kind
pointer uintptr
}
func sanitize(value any, depth int, seen map[visit]bool) any {
if value == nil {
return nil
}
if text, ok := value.(string); ok {
return sanitizeText(text)
}
v := reflect.ValueOf(value)
for v.Kind() == reflect.Interface || v.Kind() == reflect.Pointer {
if v.IsNil() {
return nil
}
key := visit{v.Kind(), v.Pointer()}
if seen[key] {
return "[circular]"
}
seen[key] = true
v = v.Elem()
}
if depth >= 5 {
return "[truncated]"
}
switch v.Kind() {
case reflect.Bool:
return v.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint()
case reflect.Float32, reflect.Float64:
return v.Float()
case reflect.String:
return sanitizeText(v.String())
case reflect.Slice, reflect.Array:
length := v.Len()
if length > 50 {
length = 50
}
out := make([]any, length)
for i := 0; i < length; i++ {
out[i] = sanitize(v.Index(i).Interface(), depth+1, seen)
}
return out
case reflect.Map:
if v.Type().Key().Kind() != reflect.String {
return sanitizeText(fmt.Sprint(value))
}
keys := v.MapKeys()
if len(keys) > 80 {
keys = keys[:80]
}
out := map[string]any{}
for _, key := range keys {
name := key.String()
if sensitiveKey.MatchString(name) {
out[name] = "[redacted]"
} else {
out[name] = sanitize(v.MapIndex(key).Interface(), depth+1, seen)
}
}
return out
case reflect.Struct:
out := map[string]any{}
typeOfValue := v.Type()
for index := 0; index < v.NumField() && index < 80; index++ {
fieldValue := v.Field(index)
if !fieldValue.CanInterface() {
continue
}
name := typeOfValue.Field(index).Name
if sensitiveKey.MatchString(name) {
out[name] = "[redacted]"
} else {
out[name] = sanitize(fieldValue.Interface(), depth+1, seen)
}
}
return out
default:
return sanitizeText(fmt.Sprint(value))
}
}
func validLevel(level Level) bool { return level == Info || level == Warning || level == Error }
func search(entry Entry) string {
details, _ := json.Marshal(entry.Details)
return strings.Join([]string{entry.Message, entry.Source, entry.Path, entry.Method, strconv.Itoa(entry.Status), entry.Stack, string(details)}, " ")
}
func defaultString(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func randomID() string {
buffer := make([]byte, 9)
if _, err := rand.Read(buffer); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(buffer)
}