Skip to content

Commit 9c6a9b9

Browse files
authored
feat: implement Nethermind block log parser (#163)
## Summary - Replace the Nethermind stub parser with a working implementation that extracts JSON payloads from "Slow block" log lines - Strips ANSI color escape sequences (reuses shared `ansiPattern` from Reth parser) - Filters lines by timestamp+pipe prefix regex and validates the JSON contains `"msg":"Slow block"` - JSON output already matches the expected `block.hash` structure used by the collector ## Test plan - [x] `go test ./pkg/blocklog/ -v` — all 30 tests pass - [x] Manual test with Nethermind client to verify block logs appear in `result.block-logs.json`
1 parent e22986b commit 9c6a9b9

2 files changed

Lines changed: 194 additions & 6 deletions

File tree

pkg/blocklog/nethermind.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,54 @@ package blocklog
22

33
import (
44
"encoding/json"
5+
"regexp"
56

67
"github.com/ethpandaops/benchmarkoor/pkg/client"
78
)
89

9-
// nethermindParser is a stub parser for Nethermind client logs.
10-
// Returns nil, false until the log format is known.
10+
// nethermindLogPattern matches Nethermind Slow block log lines (after ANSI stripping).
11+
// Format: <timestamp> | {JSON with "msg":"Slow block"}
12+
var nethermindLogPattern = regexp.MustCompile(
13+
`^\s*\d+\s+\w+\s+\d+:\d+:\d+\s*\|\s*(\{.+\})\s*$`,
14+
)
15+
16+
// nethermindParser parses JSON payloads from Nethermind client Slow block logs.
1117
type nethermindParser struct{}
1218

13-
// NewNethermindParser creates a new Nethermind log parser (stub).
19+
// NewNethermindParser creates a new Nethermind log parser.
1420
func NewNethermindParser() Parser {
1521
return &nethermindParser{}
1622
}
1723

1824
// Ensure interface compliance.
1925
var _ Parser = (*nethermindParser)(nil)
2026

21-
// ParseLine is a stub that always returns nil, false.
22-
func (p *nethermindParser) ParseLine(_ string) (json.RawMessage, bool) {
23-
return nil, false
27+
// ParseLine extracts JSON from a Nethermind Slow block log line.
28+
func (p *nethermindParser) ParseLine(line string) (json.RawMessage, bool) {
29+
// Strip ANSI escape codes — Nethermind logs include color/style sequences.
30+
line = ansiPattern.ReplaceAllString(line, "")
31+
32+
matches := nethermindLogPattern.FindStringSubmatch(line)
33+
if len(matches) < 2 {
34+
return nil, false
35+
}
36+
37+
jsonStr := matches[1]
38+
39+
// Validate that it's valid JSON and contains the expected "Slow block" message.
40+
var probe struct {
41+
Msg string `json:"msg"`
42+
}
43+
44+
if err := json.Unmarshal([]byte(jsonStr), &probe); err != nil {
45+
return nil, false
46+
}
47+
48+
if probe.Msg != "Slow block" {
49+
return nil, false
50+
}
51+
52+
return json.RawMessage(jsonStr), true
2453
}
2554

2655
// ClientType returns the client type.

pkg/blocklog/nethermind_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package blocklog
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestNethermindParser_ParseLine(t *testing.T) {
12+
parser := NewNethermindParser()
13+
14+
tests := []struct {
15+
name string
16+
line string
17+
wantOK bool
18+
checkJSON func(t *testing.T, data map[string]any)
19+
}{
20+
{
21+
name: "valid Slow block line with all fields",
22+
line: ` 20 Mar 18:44:40 | {"level":"warn","msg":"Slow block","block":{"number":1,"hash":"0x3fe6bd8e331c411dcb32e75054b2bfd3f8deb177414650782883af6f5982a014","gas_used":100000000,"gas_limit":1000000000000,"tx_count":6,"blob_count":0},"timing":{"execution_ms":654.08,"evm_ms":653.989,"blooms_ms":0.055,"receipts_root_ms":0.036,"commit_ms":0.002,"storage_merkle_ms":0.052,"state_root_ms":0.141,"state_hash_ms":0.194,"total_ms":654.275},"throughput":{"mgas_per_sec":152.84},"state_reads":{"accounts":8,"storage_slots":10,"code":0,"code_bytes":0},"state_writes":{"accounts":14,"accounts_deleted":0,"storage_slots":2,"storage_slots_deleted":0,"code":0,"code_bytes":0,"eip7702_delegations_set":0,"eip7702_delegations_cleared":0},"cache":{"account":{"hits":26,"misses":8,"hit_rate":76.47},"storage":{"hits":10,"misses":1,"hit_rate":90.91},"code":{"hits":9,"misses":0,"hit_rate":100}},"evm":{"opcodes":910,"sload":8,"sstore":10,"calls":89,"empty_calls":0,"creates":0,"self_destructs":0,"contracts_analyzed":0,"cached_contracts_used":9}}`,
23+
wantOK: true,
24+
checkJSON: func(t *testing.T, data map[string]any) {
25+
t.Helper()
26+
27+
assert.Equal(t, "warn", data["level"])
28+
assert.Equal(t, "Slow block", data["msg"])
29+
30+
block := data["block"].(map[string]any)
31+
assert.Equal(t, float64(1), block["number"])
32+
assert.Equal(t, "0x3fe6bd8e331c411dcb32e75054b2bfd3f8deb177414650782883af6f5982a014", block["hash"])
33+
assert.Equal(t, float64(100000000), block["gas_used"])
34+
assert.Equal(t, float64(1000000000000), block["gas_limit"])
35+
assert.Equal(t, float64(6), block["tx_count"])
36+
assert.Equal(t, float64(0), block["blob_count"])
37+
38+
timing := data["timing"].(map[string]any)
39+
assert.Equal(t, 654.08, timing["execution_ms"])
40+
assert.Equal(t, 653.989, timing["evm_ms"])
41+
assert.Equal(t, 0.055, timing["blooms_ms"])
42+
assert.Equal(t, 0.036, timing["receipts_root_ms"])
43+
assert.Equal(t, 0.002, timing["commit_ms"])
44+
assert.Equal(t, 0.052, timing["storage_merkle_ms"])
45+
assert.Equal(t, 0.141, timing["state_root_ms"])
46+
assert.Equal(t, 0.194, timing["state_hash_ms"])
47+
assert.Equal(t, 654.275, timing["total_ms"])
48+
49+
throughput := data["throughput"].(map[string]any)
50+
assert.Equal(t, 152.84, throughput["mgas_per_sec"])
51+
52+
stateReads := data["state_reads"].(map[string]any)
53+
assert.Equal(t, float64(8), stateReads["accounts"])
54+
assert.Equal(t, float64(10), stateReads["storage_slots"])
55+
assert.Equal(t, float64(0), stateReads["code"])
56+
assert.Equal(t, float64(0), stateReads["code_bytes"])
57+
58+
stateWrites := data["state_writes"].(map[string]any)
59+
assert.Equal(t, float64(14), stateWrites["accounts"])
60+
assert.Equal(t, float64(0), stateWrites["accounts_deleted"])
61+
assert.Equal(t, float64(2), stateWrites["storage_slots"])
62+
assert.Equal(t, float64(0), stateWrites["storage_slots_deleted"])
63+
64+
cache := data["cache"].(map[string]any)
65+
account := cache["account"].(map[string]any)
66+
assert.Equal(t, float64(26), account["hits"])
67+
assert.Equal(t, float64(8), account["misses"])
68+
assert.Equal(t, 76.47, account["hit_rate"])
69+
70+
storage := cache["storage"].(map[string]any)
71+
assert.Equal(t, float64(10), storage["hits"])
72+
assert.Equal(t, float64(1), storage["misses"])
73+
assert.Equal(t, 90.91, storage["hit_rate"])
74+
75+
code := cache["code"].(map[string]any)
76+
assert.Equal(t, float64(9), code["hits"])
77+
assert.Equal(t, float64(0), code["misses"])
78+
assert.Equal(t, float64(100), code["hit_rate"])
79+
80+
evm := data["evm"].(map[string]any)
81+
assert.Equal(t, float64(910), evm["opcodes"])
82+
assert.Equal(t, float64(8), evm["sload"])
83+
assert.Equal(t, float64(10), evm["sstore"])
84+
assert.Equal(t, float64(89), evm["calls"])
85+
},
86+
},
87+
{
88+
name: "line with ANSI escape codes",
89+
line: "\x1b[33m 20 Mar 18:44:40\x1b[m | \x1b[33m{\"level\":\"warn\",\"msg\":\"Slow block\",\"block\":{\"number\":1,\"hash\":\"0xabc\",\"gas_used\":100000000,\"tx_count\":6},\"timing\":{\"execution_ms\":654.08,\"total_ms\":654.275},\"throughput\":{\"mgas_per_sec\":152.84}}\x1b[m",
90+
wantOK: true,
91+
checkJSON: func(t *testing.T, data map[string]any) {
92+
t.Helper()
93+
94+
assert.Equal(t, "warn", data["level"])
95+
assert.Equal(t, "Slow block", data["msg"])
96+
97+
block := data["block"].(map[string]any)
98+
assert.Equal(t, float64(1), block["number"])
99+
assert.Equal(t, "0xabc", block["hash"])
100+
101+
timing := data["timing"].(map[string]any)
102+
assert.Equal(t, 654.08, timing["execution_ms"])
103+
104+
throughput := data["throughput"].(map[string]any)
105+
assert.Equal(t, 152.84, throughput["mgas_per_sec"])
106+
},
107+
},
108+
{
109+
name: "non-Slow block log line",
110+
line: ` 20 Mar 18:44:40 | {"level":"info","msg":"Block processed","block":{"number":1}}`,
111+
wantOK: false,
112+
},
113+
{
114+
name: "empty line",
115+
line: "",
116+
wantOK: false,
117+
},
118+
{
119+
name: "random text",
120+
line: "some random log output that does not match",
121+
wantOK: false,
122+
},
123+
{
124+
name: "invalid JSON after timestamp prefix",
125+
line: ` 20 Mar 18:44:40 | {not valid json}`,
126+
wantOK: false,
127+
},
128+
{
129+
name: "valid JSON but not Slow block message",
130+
line: ` 20 Mar 18:44:40 | {"level":"info","msg":"Something else","data":"value"}`,
131+
wantOK: false,
132+
},
133+
}
134+
135+
for _, tt := range tests {
136+
t.Run(tt.name, func(t *testing.T) {
137+
result, ok := parser.ParseLine(tt.line)
138+
139+
assert.Equal(t, tt.wantOK, ok)
140+
141+
if tt.wantOK {
142+
require.NotNil(t, result)
143+
144+
var parsed map[string]any
145+
err := json.Unmarshal(result, &parsed)
146+
require.NoError(t, err)
147+
148+
tt.checkJSON(t, parsed)
149+
} else {
150+
assert.Nil(t, result)
151+
}
152+
})
153+
}
154+
}
155+
156+
func TestNethermindParser_ClientType(t *testing.T) {
157+
parser := NewNethermindParser()
158+
assert.Equal(t, "nethermind", string(parser.ClientType()))
159+
}

0 commit comments

Comments
 (0)