Repository navigation
merge coroot/logparser v1.4.2: JSON parser, pattern rate limit, level fixes #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
d66d185
json parser
def 3be0d82
fix multiline parser for json logs
def dff1e4b
rate limit pattern extraction to cap CPU usage under log storms
def 4a17e74
json: treat mongodb {"$date": ...} objects under the "t" key as times…
def cbc5b5b
multiline: trust the level reported with an entry, guess only when it…
def 03c7b0c
level: recognize the MySQL/MariaDB error log priorities [Note] and [S…
def 2d6d6c9
merge coroot/logparser main (v1.4.2): JSON parser, multiline JSON, pa…
mayankpande88 6da0f06
decode a JSON log line once; accept CRLF/trailing whitespace on JSON …
mayankpande88 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,230 @@ | ||
| package logparser | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "io" | ||
| "strings" | ||
| "unicode/utf8" | ||
| ) | ||
|
|
||
| const ( | ||
| jsonMaxAttrs = 100 | ||
| jsonMaxDepth = 8 | ||
| jsonMaxValueLen = 4096 | ||
| ) | ||
|
|
||
| var ( | ||
| jsonMessageKeys = map[string]int{ | ||
| "msg": 0, | ||
| "message": 1, | ||
| "@m": 2, | ||
| "@mt": 3, | ||
| "body": 4, | ||
| "log": 5, | ||
| } | ||
| jsonLevelKeys = map[string]int{ | ||
| "level": 0, | ||
| "severity": 1, | ||
| "loglevel": 2, | ||
| "log.level": 3, | ||
| "lvl": 4, | ||
| "@l": 5, | ||
| "severity_text": 6, | ||
| "levelname": 7, | ||
| } | ||
| jsonTimestampKeys = map[string]bool{ | ||
| "time": true, "timestamp": true, "ts": true, "t": true, "@t": true, "@timestamp": true, "datetime": true, "asctime": true, | ||
| } | ||
| ) | ||
|
|
||
| type JsonLog struct { | ||
| Level Level | ||
| Message string | ||
| Attributes map[string]string | ||
| } | ||
|
|
||
| func ParseJsonLog(content string) *JsonLog { | ||
| fields := decodeJsonObject(content) | ||
| if fields == nil { | ||
| return nil | ||
| } | ||
| return jsonLogFromFields(fields) | ||
| } | ||
|
|
||
| // decodeJsonObject decodes a log line that holds a single, non-empty JSON | ||
| // object, or returns nil. Surrounding whitespace, such as the \r of a CRLF | ||
| // line ending, is ignored. Numbers are kept as json.Number. | ||
| func decodeJsonObject(content string) map[string]any { | ||
| content = strings.TrimSpace(content) | ||
| if len(content) < 2 || content[0] != '{' || content[len(content)-1] != '}' { | ||
| return nil | ||
| } | ||
| d := json.NewDecoder(strings.NewReader(content)) | ||
| d.UseNumber() | ||
| var fields map[string]any | ||
| if d.Decode(&fields) != nil || len(fields) == 0 { | ||
| return nil | ||
| } | ||
| if _, err := d.Token(); err != io.EOF { | ||
| return nil | ||
| } | ||
| return fields | ||
| } | ||
|
|
||
| // jsonLogFromFields extracts the message, level and attributes from a decoded | ||
| // JSON log line. | ||
| func jsonLogFromFields(fields map[string]any) *JsonLog { | ||
| res := &JsonLog{Attributes: map[string]string{}} | ||
| var msgKey, lvlKey string | ||
| var msgPrio, lvlPrio int | ||
| var msgVal string | ||
| var lvlRaw any | ||
| for k, v := range fields { | ||
| lk := strings.ToLower(k) | ||
| if p, ok := jsonMessageKeys[lk]; ok { | ||
| if s, ok := v.(string); ok { | ||
| if msgKey == "" || p < msgPrio || (p == msgPrio && k < msgKey) { | ||
| if msgKey != "" { | ||
| flattenJsonField(msgKey, msgVal, res.Attributes, jsonMaxDepth) | ||
| } | ||
| msgKey, msgPrio, msgVal = k, p, s | ||
| } else { | ||
| flattenJsonField(k, v, res.Attributes, jsonMaxDepth) | ||
| } | ||
| continue | ||
| } | ||
| } else if p, ok := jsonLevelKeys[lk]; ok { | ||
| if lvl := jsonLevelFromValue(v); lvl != LevelUnknown { | ||
| if lvlKey == "" || p < lvlPrio || (p == lvlPrio && k < lvlKey) { | ||
| if lvlKey != "" { | ||
| flattenJsonField(lvlKey, lvlRaw, res.Attributes, jsonMaxDepth) | ||
| } | ||
| lvlKey, lvlPrio, lvlRaw = k, p, v | ||
| res.Level = lvl | ||
| } else { | ||
| flattenJsonField(k, v, res.Attributes, jsonMaxDepth) | ||
| } | ||
| continue | ||
| } | ||
| } else if jsonTimestampKeys[lk] && jsonIsTimestampValue(v) { | ||
| continue | ||
| } | ||
| flattenJsonField(k, v, res.Attributes, jsonMaxDepth) | ||
| } | ||
| res.Message = strings.TrimSuffix(msgVal, "\n") | ||
| return res | ||
| } | ||
|
|
||
| func jsonIsTimestampValue(v any) bool { | ||
| switch value := v.(type) { | ||
| case string, json.Number: | ||
| return true | ||
| case map[string]any: | ||
| if len(value) != 1 { | ||
| return false | ||
| } | ||
| if _, ok := value["$date"]; ok { // mongodb | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| func jsonLevelFromValue(v any) Level { | ||
| switch value := v.(type) { | ||
| case string: | ||
| return jsonLevelFromString(value) | ||
| case json.Number: | ||
| return jsonLevelFromNumber(value) | ||
| } | ||
| return LevelUnknown | ||
| } | ||
|
|
||
| func flattenJsonField(key string, value any, attrs map[string]string, depth int) { | ||
| if len(attrs) >= jsonMaxAttrs { | ||
| return | ||
| } | ||
| switch v := value.(type) { | ||
| case nil: | ||
| attrs[key] = "" | ||
| case string: | ||
| attrs[key] = truncateUtf8(v, jsonMaxValueLen) | ||
| case bool: | ||
| if v { | ||
| attrs[key] = "true" | ||
| } else { | ||
| attrs[key] = "false" | ||
| } | ||
| case json.Number: | ||
| attrs[key] = v.String() | ||
| case map[string]any: | ||
| if depth <= 0 { | ||
| attrs[key] = jsonEncode(v) | ||
| return | ||
| } | ||
| for k, vv := range v { | ||
| flattenJsonField(key+"."+k, vv, attrs, depth-1) | ||
| } | ||
| default: | ||
| attrs[key] = jsonEncode(v) | ||
| } | ||
| } | ||
|
|
||
| func jsonEncode(v any) string { | ||
| data, err := json.Marshal(v) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| return truncateUtf8(string(data), jsonMaxValueLen) | ||
| } | ||
|
|
||
| func truncateUtf8(s string, limit int) string { | ||
| if len(s) <= limit { | ||
| return s | ||
| } | ||
| for limit > 0 && !utf8.RuneStart(s[limit]) { | ||
| limit-- | ||
| } | ||
| return s[:limit] | ||
| } | ||
|
|
||
| func jsonLevelFromString(s string) Level { | ||
| if len(s) > 16 { | ||
| return LevelUnknown | ||
| } | ||
| switch strings.ToLower(s) { | ||
| case "trace", "trc", "debug", "dbg", "verbose": | ||
| return LevelDebug | ||
| case "info", "inf", "information", "informational", "notice": | ||
| return LevelInfo | ||
| case "warn", "wrn", "warning": | ||
| return LevelWarning | ||
| case "error", "err": | ||
| return LevelError | ||
| case "fatal", "ftl", "critical", "crit", "panic", "dpanic", "alert", "emerg", "emergency": | ||
| return LevelCritical | ||
| } | ||
| return LevelUnknown | ||
| } | ||
|
|
||
| func jsonLevelFromNumber(n json.Number) Level { | ||
| v, err := n.Int64() | ||
| if err != nil { | ||
| return LevelUnknown | ||
| } | ||
| switch { | ||
| case v < 10: | ||
| return LevelUnknown | ||
| case v < 30: | ||
| return LevelDebug | ||
| case v < 40: | ||
| return LevelInfo | ||
| case v < 50: | ||
| return LevelWarning | ||
| case v < 60: | ||
| return LevelError | ||
| case v < 100: | ||
| return LevelCritical | ||
| } | ||
| return LevelUnknown | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| package logparser | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // Decoding a JSON line once (UseNumber) must give the same pattern content and | ||
| // level as the json.Unmarshal path, or pattern hashes would change. | ||
| func TestStructuredFromJSONMatchesUnmarshal(t *testing.T) { | ||
| lines := []string{ | ||
| `{"level":"error","msg":"payment failed","order_id":"ord-1"}`, | ||
| `{"severity":"WARNING","message":"retry 3 of 5","attempt":3}`, | ||
| `{"lvl":"info","error":500,"reason":"upstream returned 1234567890123"}`, | ||
| `{"level":"debug","err":12.5e3}`, | ||
| `{"ts":1700000000.123,"code":1234567,"nested":{"a":1,"b":[1,2.5,"x"],"c":{"d":1e21}},"ok":true,"none":null}`, | ||
| `{"log_type":"error","data":[{"id":9007199254740993},{"id":-0.000001}]}`, | ||
| ` {"msg":"padded line","n":42} `, | ||
| } | ||
| for _, line := range lines { | ||
| m1, l1, ok := parseJSONLog(line) | ||
| require.True(t, ok, line) | ||
| fields := decodeJsonObject(line) | ||
| require.NotNil(t, fields, line) | ||
| m2, l2 := structuredFromJSON(fields) | ||
| assert.Equal(t, m1, m2, line) | ||
| assert.Equal(t, l1, l2, line) | ||
| } | ||
| } | ||
|
|
||
| func TestJsonLogCRLF(t *testing.T) { | ||
| jl := ParseJsonLog("{\"level\":\"error\",\"msg\":\"payment failed\"}\r") | ||
| require.NotNil(t, jl) | ||
| assert.Equal(t, "payment failed", jl.Message) | ||
| assert.Equal(t, LevelError, jl.Level) | ||
|
|
||
| m := &MultilineCollector{} | ||
| assert.True(t, m.isNextMessage("{\"msg\":\"a\"}\r")) | ||
| assert.True(t, m.isNextMessage("{\"msg\":\"a\"} ")) | ||
| } | ||
|
|
||
| // The same JSON line gets the same pattern hash whether or not JSON parsing | ||
| // (message and attributes) is enabled. | ||
| func TestPatternHashIndependentOfParseJson(t *testing.T) { | ||
| line := `{"level":"error","msg":"payment failed for order 42","order_id":"ord-1","duration_ms":138.5}` | ||
| hash := func(parseJson bool) (h string, attrs map[string]string) { | ||
| p := &Parser{ | ||
| patterns: map[patternKey]*patternStat{}, | ||
| patternsPerLevel: map[Level]int{}, | ||
| patternsPerLevelLimit: 256, | ||
| parseJson: parseJson, | ||
| onMsgCb: func(ts time.Time, level Level, patternHash string, msg string, a map[string]string) { | ||
| h, attrs = patternHash, a | ||
| }, | ||
| } | ||
| p.inc(Message{Timestamp: time.Now(), Content: line, Level: LevelUnknown}) | ||
| return | ||
| } | ||
| h1, a1 := hash(false) | ||
| h2, a2 := hash(true) | ||
| assert.NotEmpty(t, h1) | ||
| assert.Equal(t, h1, h2) | ||
| assert.Nil(t, a1) | ||
| assert.Equal(t, "ord-1", a2["order_id"]) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.