319 lines
8.3 KiB
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)
|
|
}
|