diff --git a/.vscode/settings.json b/.vscode/settings.json index 6de01c52..ddd1ed2d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "i18n-ally.localesPaths": ["locales"], "i18n-ally.keystyle": "nested", - "cSpell.words": ["healthcheck"], + "cSpell.words": ["healthcheck", "orderedmap"], "editor.formatOnSave": true, "i18n-ally.extract.autoDetect": true } diff --git a/go.mod b/go.mod index a1729ff8..cdaefd3e 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,6 @@ require ( github.com/PuerkitoBio/goquery v1.9.2 github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/jwtauth/v5 v5.3.1 - github.com/go-logfmt/logfmt v0.6.0 github.com/goccy/go-json v0.10.3 github.com/puzpuzpuz/xsync/v3 v3.1.0 github.com/wk8/go-ordered-map/v2 v2.1.8 diff --git a/go.sum b/go.sum index 041b6a73..213ef58a 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,6 @@ github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/jwtauth/v5 v5.3.1 h1:1ePWrjVctvp1tyBq5b/2ER8Th/+RbYc7x4qNsc5rh5A= github.com/go-chi/jwtauth/v5 v5.3.1/go.mod h1:6Fl2RRmWXs3tJYE1IQGX81FsPoGqDwq9c15j52R5q80= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= diff --git a/internal/docker/event_generator.go b/internal/docker/event_generator.go index eb780c9d..dbb9c37c 100644 --- a/internal/docker/event_generator.go +++ b/internal/docker/event_generator.go @@ -8,7 +8,6 @@ import ( "fmt" "hash/fnv" "io" - "regexp" "strings" "sync" "time" @@ -17,7 +16,6 @@ import ( orderedmap "github.com/wk8/go-ordered-map/v2" - "github.com/go-logfmt/logfmt" log "github.com/sirupsen/logrus" ) @@ -160,9 +158,6 @@ func readEvent(reader *bufio.Reader, tty bool) (string, StdType, error) { } } -var validLogFmtMessage = regexp.MustCompile(`([a-zA-Z0-9_.-]+)=(?:(?:"(.*)")|(?:(?:([^\s]+)[\s])))`) -var validLogFmtKey = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) - func createEvent(message string, streamType StdType) *LogEvent { h := fnv.New32a() h.Write([]byte(message)) @@ -185,27 +180,8 @@ func createEvent(message string, streamType StdType) *LogEvent { } else { logEvent.Message = data } - } else if validLogFmtMessage.MatchString(message) { - buffer := bufPool.Get().(*bytes.Buffer) - buffer.Reset() - defer bufPool.Put(buffer) - buffer.WriteString(message) - decoder := logfmt.NewDecoder(buffer) - data := make(map[string]string) - decoder.ScanRecord() - allValid := true - for decoder.ScanKeyval() { - key := decoder.Key() - value := decoder.Value() - if !validLogFmtKey.Match(key) { - allValid = false - break - } - data[string(key)] = string(value) - } - if allValid && len(data) > 1 { - logEvent.Message = data - } + } else if data, err := ParseLogFmt(message); err == nil { + logEvent.Message = data } } } diff --git a/internal/docker/event_generator_test.go b/internal/docker/event_generator_test.go index 7c4f4e62..80dc129d 100644 --- a/internal/docker/event_generator_test.go +++ b/internal/docker/event_generator_test.go @@ -127,7 +127,17 @@ func Test_createEvent(t *testing.T) { Message: "123", }, }, + { + name: "invalid logfmt message", + args: args{ + message: "2020-05-13T18:55:37.772853839Z sample text with=equal sign", + }, + want: &LogEvent{ + Message: "sample text with=equal sign", + }, + }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := createEvent(tt.args.message, STDOUT); !reflect.DeepEqual(got.Message, tt.want.Message) { diff --git a/internal/docker/level_guesser.go b/internal/docker/level_guesser.go index c122e6f7..681dc0df 100644 --- a/internal/docker/level_guesser.go +++ b/internal/docker/level_guesser.go @@ -52,6 +52,11 @@ func guessLogLevel(logEvent *LogEvent) string { } } + case *orderedmap.OrderedMap[string, string]: + if level, ok := value.Get("level"); ok { + return strings.ToLower(level) + } + case map[string]interface{}: if level, ok := value["level"].(string); ok { return strings.ToLower(level) diff --git a/internal/docker/level_guesser_test.go b/internal/docker/level_guesser_test.go index 52a8db40..127926b1 100644 --- a/internal/docker/level_guesser_test.go +++ b/internal/docker/level_guesser_test.go @@ -7,10 +7,6 @@ import ( ) func TestGuessLogLevel(t *testing.T) { - ordereddata := orderedmap.New[string, any]() - ordereddata.Set("key", "value") - ordereddata.Set("level", "info") - tests := []struct { input any expected string @@ -35,7 +31,18 @@ func TestGuessLogLevel(t *testing.T) { {map[string]interface{}{"level": "INFO"}, "info"}, {map[string]string{"level": "info"}, "info"}, {map[string]string{"level": "INFO"}, "info"}, - {ordereddata, "info"}, + {orderedmap.New[string, string]( + orderedmap.WithInitialData( + orderedmap.Pair[string, string]{Key: "key", Value: "value"}, + orderedmap.Pair[string, string]{Key: "level", Value: "info"}, + ), + ), "info"}, + {orderedmap.New[string, any]( + orderedmap.WithInitialData( + orderedmap.Pair[string, any]{Key: "key", Value: "value"}, + orderedmap.Pair[string, any]{Key: "level", Value: "info"}, + ), + ), "info"}, } for _, test := range tests { diff --git a/internal/docker/logfmt.go b/internal/docker/logfmt.go new file mode 100644 index 00000000..ade2b20f --- /dev/null +++ b/internal/docker/logfmt.go @@ -0,0 +1,86 @@ +package docker + +import ( + "errors" + + orderedmap "github.com/wk8/go-ordered-map/v2" +) + +// ParseLogFmt parses a log entry in logfmt format and returns a map of key-value pairs. +func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) { + result := orderedmap.New[string, string]() + var key, value string + inQuotes, escaping, isKey := false, false, true + start := 0 + + for i := 0; i < len(log); i++ { + char := log[i] + + if isKey { + if char == '=' { + if i == start { + return nil, errors.New("invalid format: key is empty") + } + key = log[start:i] + isKey = false + start = i + 1 + } else if char == ' ' { + if i > start { + return nil, errors.New("invalid format: unexpected space in key") + } + } + + } else { + if inQuotes { + if escaping { + escaping = false + } else if char == '\\' { + escaping = true + } else if char == '"' { + value = log[start:i] + result.Set(key, value) + inQuotes = false + isKey = true + start = i + 2 + } + } else { + if char == '"' { + inQuotes = true + start = i + 1 + } else if char == ' ' { + if i == start { + return nil, errors.New("invalid format: value is empty") + } + value = log[start:i] + result.Set(key, value) + isKey = true + start = i + 1 + } + } + } + } + + // Handle the last key-value pair if there is no trailing space + if !isKey && start < len(log) { + if inQuotes { + return nil, errors.New("invalid format: unclosed quotes") + } + value = log[start:] + result.Set(key, value) + } else if isKey && start < len(log) { + return nil, errors.New("invalid format: unexpected key without value") + } + + if !isKey { + if inQuotes { + return nil, errors.New("invalid format: unclosed quotes") + } + if start >= len(log) { + return nil, errors.New("invalid format: value is empty") + } + value = log[start:] + result.Set(key, value) + } + + return result, nil +} diff --git a/internal/docker/logfmt_test.go b/internal/docker/logfmt_test.go new file mode 100644 index 00000000..717ad20e --- /dev/null +++ b/internal/docker/logfmt_test.go @@ -0,0 +1,82 @@ +package docker + +import ( + "encoding/json" + "reflect" + "testing" + + orderedmap "github.com/wk8/go-ordered-map/v2" +) + +func TestParseLog(t *testing.T) { + + tests := []struct { + name string + log string + want *orderedmap.OrderedMap[string, string] + wantErr bool + }{ + { + name: "Valid logfmt log", + log: `time="2024-06-02T14:30:42Z" level=debug msg="container e23e04da2cb9 started"`, + want: orderedmap.New[string, string]( + orderedmap.WithInitialData( + orderedmap.Pair[string, string]{Key: "time", Value: "2024-06-02T14:30:42Z"}, + orderedmap.Pair[string, string]{Key: "level", Value: "debug"}, + orderedmap.Pair[string, string]{Key: "msg", Value: "container e23e04da2cb9 started"}, + ), + ), + wantErr: false, + }, + { + name: "Random test with equal sign", + log: "foo bar=baz", + want: nil, + wantErr: true, + }, + { + name: "Invalid log with key without value", + log: "key1=value1 key2=", + want: nil, + wantErr: true, + }, + { + name: "Valid log", + log: "key1=value1 key2=value2", + want: orderedmap.New[string, string]( + orderedmap.WithInitialData( + orderedmap.Pair[string, string]{Key: "key1", Value: "value1"}, + orderedmap.Pair[string, string]{Key: "key2", Value: "value2"}, + ), + ), + wantErr: false, + }, + { + name: "Invalid log with unclosed quotes", + log: "key1=\"value1 key2=value2", + want: nil, + wantErr: true, + }, + { + name: "Plain text log", + log: "foo bar baz", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseLogFmt(tt.log) + if (err != nil) != tt.wantErr { + t.Errorf("ParseLogFmt() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + jsonGot, _ := json.MarshalIndent(got, "", " ") + jsonWant, _ := json.MarshalIndent(tt.want, "", " ") + t.Errorf("ParseLogFmt() = %v, want %v", string(jsonGot), string(jsonWant)) + } + }) + } +}