Skip to content

Commit 6397ee3

Browse files
committed
Add LogParser and Logger utilities
1 parent 31d3079 commit 6397ee3

File tree

5 files changed

+495
-0
lines changed

5 files changed

+495
-0
lines changed

utils/log_entry.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package utils
2+
3+
import (
4+
"log/slog"
5+
"time"
6+
)
7+
8+
// LogEntry represents a parsed log message with extracted components
9+
type LogEntry struct {
10+
Time time.Time
11+
Thread string
12+
Category string
13+
Level slog.Level
14+
Source string
15+
Function string
16+
File string
17+
Path string
18+
LineNumber int
19+
Message string
20+
}
21+
22+
func (entry *LogEntry) GetSlogAttributes() []slog.Attr {
23+
var attrs []slog.Attr
24+
25+
if !entry.Time.IsZero() {
26+
attrs = append(attrs, slog.Time("time", entry.Time))
27+
}
28+
29+
if entry.Thread != "" {
30+
attrs = append(attrs, slog.String("thread", entry.Thread))
31+
}
32+
33+
if entry.Category != "" {
34+
attrs = append(attrs, slog.String("category", entry.Category))
35+
}
36+
37+
if entry.Source != "" {
38+
attrs = append(attrs, slog.String("source", entry.Source))
39+
}
40+
41+
if entry.Path != "" {
42+
attrs = append(attrs, slog.String("path", entry.Path))
43+
}
44+
45+
if entry.File != "" {
46+
attrs = append(attrs, slog.String("file", entry.File))
47+
}
48+
49+
if entry.LineNumber > 0 {
50+
attrs = append(attrs, slog.Int("line_number", entry.LineNumber))
51+
}
52+
53+
if entry.Function != "" {
54+
attrs = append(attrs, slog.String("function", entry.Function))
55+
}
56+
57+
return attrs
58+
}

utils/log_parser.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package utils
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strconv"
7+
"strings"
8+
"time"
9+
10+
"log/slog"
11+
12+
"github.com/stringintech/go-bitcoinkernel/kernel"
13+
)
14+
15+
// LogParser handles parsing of raw log messages from Kernel
16+
type LogParser struct {
17+
masterRegex *regexp.Regexp
18+
options kernel.LoggingOptions
19+
}
20+
21+
func NewLogParser(options kernel.LoggingOptions) (*LogParser, error) {
22+
parser := &LogParser{
23+
options: options,
24+
}
25+
26+
// Compile regex patterns based on enabled options
27+
if err := parser.compileRegexes(); err != nil {
28+
return nil, fmt.Errorf("failed to compile regex patterns: %w", err)
29+
}
30+
31+
return parser, nil
32+
}
33+
34+
// Parse extracts structured information from a raw log message using single regex
35+
func (p *LogParser) Parse(message string) (*LogEntry, error) {
36+
entry := LogEntry{
37+
Message: message,
38+
Level: slog.LevelInfo, // Default level
39+
}
40+
41+
if p.masterRegex == nil {
42+
return nil, fmt.Errorf("regex not compiled")
43+
}
44+
45+
matches := p.masterRegex.FindStringSubmatch(strings.TrimSpace(message))
46+
if matches == nil {
47+
return nil, fmt.Errorf("failed to parse log format")
48+
}
49+
50+
// Extract groups based on regex pattern:
51+
// 1: timestamp, 2: thread, 3: source location, 4: function, 5: category, 6: level, 7: message
52+
if len(matches) < 8 {
53+
return nil, fmt.Errorf("insufficient regex groups: expected 8, got %d", len(matches))
54+
}
55+
56+
// Parse timestamp
57+
if matches[1] != "" {
58+
// supports parsing time with/without microseconds
59+
ts, err := time.Parse(time.RFC3339, matches[1])
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to parse timestamp: %w", err)
62+
}
63+
entry.Time = ts
64+
}
65+
66+
// Parse thread
67+
entry.Thread = matches[2]
68+
69+
// Parse source location and extract filename/line number
70+
if matches[3] != "" {
71+
entry.Source = matches[3]
72+
if parts := strings.Split(matches[3], ":"); len(parts) >= 2 {
73+
entry.Path = strings.TrimSpace(parts[0])
74+
lineNum, err := strconv.Atoi(parts[len(parts)-1])
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to parse line number: %w", err)
77+
}
78+
entry.LineNumber = lineNum
79+
// Extract the filename from the full path
80+
if idx := strings.LastIndex(entry.Path, "/"); idx != -1 {
81+
entry.File = entry.Path[idx+1:]
82+
} else {
83+
entry.File = entry.Path
84+
}
85+
}
86+
}
87+
88+
// Parse function name
89+
entry.Function = matches[4]
90+
91+
// Parse category and level
92+
entry.Category = matches[5]
93+
if matches[6] != "" {
94+
entry.Level = parseLogLevel(matches[6])
95+
}
96+
97+
// Extract message
98+
entry.Message = strings.TrimSpace(matches[7])
99+
100+
return &entry, nil
101+
}
102+
103+
// compileRegexes builds and compiles a single masterRegex for parsing
104+
func (p *LogParser) compileRegexes() error {
105+
pattern := "^"
106+
107+
// Time group (optional)
108+
if p.options.LogTimestamps {
109+
//TODO consider p.options.LogTimeMicros
110+
pattern += `(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)\s+`
111+
} else {
112+
pattern += "()"
113+
}
114+
115+
// Thread name group (optional)
116+
if p.options.LogThreadNames {
117+
pattern += `\[([^\]]+)\]\s+`
118+
} else {
119+
pattern += "()"
120+
}
121+
122+
// Source location group (optional)
123+
if p.options.LogSourceLocations {
124+
pattern += `\[([^\]]+)\]\s+`
125+
} else {
126+
pattern += "()"
127+
}
128+
129+
// Function name group (optional)
130+
if p.options.LogSourceLocations {
131+
pattern += `\[([^\]]+)\]\s+`
132+
} else {
133+
pattern += "()"
134+
}
135+
136+
// Category and level group (optional)
137+
if p.options.AlwaysPrintCategoryLevel {
138+
pattern += `\[([^:]+):([^\]]+)\]\s+`
139+
} else {
140+
pattern += "()()"
141+
}
142+
143+
// Message (everything remaining)
144+
pattern += "(.+)$"
145+
146+
var err error
147+
p.masterRegex, err = regexp.Compile(pattern)
148+
if err != nil {
149+
return fmt.Errorf("failed to compile master regex: %w", err)
150+
}
151+
152+
return nil
153+
}
154+
155+
// parseLogLevel converts string log level to slog.Level
156+
func parseLogLevel(levelStr string) slog.Level {
157+
switch strings.ToLower(levelStr) {
158+
case "trace":
159+
return slog.LevelDebug - 4 // Custom trace level
160+
case "debug":
161+
return slog.LevelDebug
162+
case "info":
163+
return slog.LevelInfo
164+
default:
165+
return slog.LevelInfo
166+
}
167+
}

utils/log_parser_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package utils
2+
3+
import (
4+
"fmt"
5+
"github.com/stringintech/go-bitcoinkernel/kernel"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestLogParserParse(t *testing.T) {
11+
timeStr := "2025-03-19T12:14:55Z"
12+
thread := "unknown"
13+
filename := "context.cpp"
14+
path := fmt.Sprintf("depend/bitcoin/src/kernel/%s", filename)
15+
lineno := 20
16+
function := "operator()"
17+
category := "all"
18+
level := "info"
19+
msg := "Using the 'arm_shani(1way,2way)' SHA256 implementation"
20+
logString := fmt.Sprintf("%s [%s] [%s:%d] [%s] [%s:%s] %s",
21+
timeStr, thread, path, lineno, function, category, level, msg)
22+
23+
options := kernel.LoggingOptions{
24+
LogTimestamps: true,
25+
LogTimeMicros: false,
26+
LogThreadNames: true,
27+
LogSourceLocations: true,
28+
AlwaysPrintCategoryLevel: true,
29+
}
30+
31+
parser, err := NewLogParser(options)
32+
if err != nil {
33+
t.Fatalf("Failed to create log parser: %v", err)
34+
}
35+
36+
// Parse the log string
37+
entry, err := parser.Parse(logString)
38+
if err != nil {
39+
t.Fatalf("Parse error: %v", err)
40+
}
41+
42+
expectedTime, _ := time.Parse(time.RFC3339, timeStr)
43+
if !entry.Time.Equal(expectedTime) {
44+
t.Errorf("Expected timestamp %v, got %v", expectedTime, entry.Time)
45+
}
46+
47+
if entry.Thread != thread {
48+
t.Errorf("Expected thread %s, got %s", thread, entry.Thread)
49+
}
50+
51+
if entry.Path != path {
52+
t.Errorf("Expected path %s, got %s", filename, entry.File)
53+
}
54+
55+
if entry.File != filename {
56+
t.Errorf("Expected filename %s, got %s", filename, entry.File)
57+
}
58+
59+
if entry.LineNumber != lineno {
60+
t.Errorf("Expected line number %d, got %d", lineno, entry.LineNumber)
61+
}
62+
63+
if entry.Function != function {
64+
t.Errorf("Expected function %s, got %s", function, entry.Function)
65+
}
66+
67+
if entry.Category != category {
68+
t.Errorf("Expected category %s, got %s", category, entry.Category)
69+
}
70+
71+
expectedLevel := parseLogLevel(level)
72+
if entry.Level != expectedLevel {
73+
t.Errorf("Expected level %v, got %v", expectedLevel, entry.Level)
74+
}
75+
76+
if entry.Message != msg {
77+
t.Errorf("Expected message %s, got %s", msg, entry.Message)
78+
}
79+
}
80+
81+
// TestRegexCompilation tests regex compilation with different options
82+
func TestRegexCompilation(t *testing.T) {
83+
testCases := []struct {
84+
name string
85+
options kernel.LoggingOptions
86+
}{
87+
{
88+
name: "no options",
89+
options: kernel.LoggingOptions{
90+
LogTimestamps: false,
91+
LogTimeMicros: false,
92+
LogThreadNames: false,
93+
LogSourceLocations: false,
94+
AlwaysPrintCategoryLevel: false,
95+
},
96+
},
97+
{
98+
name: "timestamps only",
99+
options: kernel.LoggingOptions{
100+
LogTimestamps: true,
101+
LogTimeMicros: false,
102+
LogThreadNames: false,
103+
LogSourceLocations: false,
104+
AlwaysPrintCategoryLevel: false,
105+
},
106+
},
107+
{
108+
name: "microsecond timestamps",
109+
options: kernel.LoggingOptions{
110+
LogTimestamps: true,
111+
LogTimeMicros: true,
112+
LogThreadNames: false,
113+
LogSourceLocations: false,
114+
AlwaysPrintCategoryLevel: false,
115+
},
116+
},
117+
{
118+
name: "all options",
119+
options: kernel.LoggingOptions{
120+
LogTimestamps: true,
121+
LogTimeMicros: true,
122+
LogThreadNames: true,
123+
LogSourceLocations: true,
124+
AlwaysPrintCategoryLevel: true,
125+
},
126+
},
127+
}
128+
129+
for _, tc := range testCases {
130+
t.Run(tc.name, func(t *testing.T) {
131+
parser := &LogParser{options: tc.options}
132+
err := parser.compileRegexes()
133+
if err != nil {
134+
t.Errorf("Failed to compile regexes for %s: %v", tc.name, err)
135+
}
136+
137+
if parser.masterRegex == nil {
138+
t.Errorf("Expected regex to be compiled for %s", tc.name)
139+
}
140+
})
141+
}
142+
}

0 commit comments

Comments
 (0)