diff --git a/pkg/blocklog/ethrex.go b/pkg/blocklog/ethrex.go index f6d418d..96ad22e 100644 --- a/pkg/blocklog/ethrex.go +++ b/pkg/blocklog/ethrex.go @@ -2,15 +2,36 @@ package blocklog import ( "encoding/json" + "regexp" + "strconv" "github.com/ethpandaops/benchmarkoor/pkg/client" ) -// ethrexParser is a stub parser for ethrex client logs. -// Returns nil, false until the log format is known. +// ethrexLogPattern matches the summary header that ethrex emits per imported +// block from Blockchain::print_add_block_pipeline_logs (the engine_newPayload +// execution path). After ANSI stripping the header looks like: +// +// [METRIC] BLOCK 24358000 0x | 1.234 Ggas/s | 567.00 ms | 150 txs | 700 Mgas (93%) +// +// The block hash is optional: it is only present on ethrex builds that include +// it in the header. benchmarkoor's collector can only associate a log with a +// test when block.hash is present, so a hash-less build parses but never +// matches. +// +// Note: ethrex follows this header with separate "|- validate/exec/merkle/store" +// lines. Those are distinct log lines, so the collector (which matches one +// payload per line) cannot fold them into this payload; only the header fields +// are captured. +var ethrexLogPattern = regexp.MustCompile( + `\[METRIC\] BLOCK (\d+)(?:\s+(0x[0-9a-fA-F]+))? \| ` + + `([0-9.]+) Ggas/s \| ([0-9.]+) ms \| (\d+) txs \| ([0-9.]+) Mgas \((\d+)%\)`, +) + +// ethrexParser parses metrics from ethrex block execution throughput logs. type ethrexParser struct{} -// NewEthrexParser creates a new ethrex log parser (stub). +// NewEthrexParser creates a new ethrex log parser. func NewEthrexParser() Parser { return ðrexParser{} } @@ -18,12 +39,61 @@ func NewEthrexParser() Parser { // Ensure interface compliance. var _ Parser = (*ethrexParser)(nil) -// ParseLine is a stub that always returns nil, false. -func (p *ethrexParser) ParseLine(_ string) (json.RawMessage, bool) { - return nil, false +// ParseLine extracts metrics from an ethrex per-block metric header and returns +// them as a nested JSON structure matching the shape used by the other client +// parsers ({ "block": { "hash": ... }, ... }). +func (p *ethrexParser) ParseLine(line string) (json.RawMessage, bool) { + // Strip ANSI escape codes — ethrex colorizes stdout logs when on a TTY. + line = ansiPattern.ReplaceAllString(line, "") + + matches := ethrexLogPattern.FindStringSubmatch(line) + if matches == nil { + return nil, false + } + + block := map[string]any{ + "number": parseInt(matches[1]), + "gas_used_mgas": parseFloat(matches[6]), + "gas_used_pct": parseInt(matches[7]), + "tx_count": parseInt(matches[5]), + } + // matches[2] (hash) is only present on builds that log it; the collector + // needs block.hash to associate the log with a test. + if matches[2] != "" { + block["hash"] = matches[2] + } + + result := map[string]any{ + "level": "info", + "msg": "Block execution throughput", + "block": block, + "timing": map[string]any{"total_ms": parseFloat(matches[4])}, + "throughput": map[string]any{"ggas_per_sec": parseFloat(matches[3])}, + } + + data, err := json.Marshal(result) + if err != nil { + return nil, false + } + + return json.RawMessage(data), true } // ClientType returns the client type. func (p *ethrexParser) ClientType() client.ClientType { return client.ClientEthrex } + +// parseInt parses a base-10 integer, returning 0 on failure. +func parseInt(s string) int64 { + i, _ := strconv.ParseInt(s, 10, 64) + + return i +} + +// parseFloat parses a float, returning 0 on failure. +func parseFloat(s string) float64 { + f, _ := strconv.ParseFloat(s, 64) + + return f +} diff --git a/pkg/blocklog/ethrex_test.go b/pkg/blocklog/ethrex_test.go new file mode 100644 index 0000000..4e7ec59 --- /dev/null +++ b/pkg/blocklog/ethrex_test.go @@ -0,0 +1,146 @@ +package blocklog + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEthrexParser_ParseLine(t *testing.T) { + parser := NewEthrexParser() + + tests := []struct { + name string + line string + wantOK bool + // checkJSON is called when wantOK is true to verify the parsed output. + checkJSON func(t *testing.T, data map[string]any) + }{ + { + name: "pipeline header with hash", + line: `2026-05-27T14:00:00.123456Z INFO [METRIC] BLOCK 24358000 0xc957abc123 | 1.234 Ggas/s | 567.00 ms | 150 txs | 700 Mgas (93%)`, + wantOK: true, + checkJSON: func(t *testing.T, data map[string]any) { + t.Helper() + + assert.Equal(t, "info", data["level"]) + assert.Equal(t, "Block execution throughput", data["msg"]) + + block := data["block"].(map[string]any) + assert.Equal(t, float64(24358000), block["number"]) + assert.Equal(t, "0xc957abc123", block["hash"]) + assert.Equal(t, float64(700), block["gas_used_mgas"]) + assert.Equal(t, float64(93), block["gas_used_pct"]) + assert.Equal(t, float64(150), block["tx_count"]) + + throughput := data["throughput"].(map[string]any) + assert.Equal(t, 1.234, throughput["ggas_per_sec"]) + + timing := data["timing"].(map[string]any) + assert.Equal(t, float64(567), timing["total_ms"]) + }, + }, + { + name: "current ethrex header without hash still parses (but lacks block.hash)", + line: `2026-05-27T14:00:00.123456Z INFO [METRIC] BLOCK 24358000 | 1.234 Ggas/s | 567.00 ms | 150 txs | 700 Mgas (93%)`, + wantOK: true, + checkJSON: func(t *testing.T, data map[string]any) { + t.Helper() + + block := data["block"].(map[string]any) + assert.Equal(t, float64(24358000), block["number"]) + _, hasHash := block["hash"] + assert.False(t, hasHash, "no hash should be set when the log omits it") + }, + }, + { + name: "zero-gas block", + line: `2026-05-27T14:00:00.123456Z INFO [METRIC] BLOCK 100 0xdeadbeef | 0.000 Ggas/s | 12.00 ms | 0 txs | 0 Mgas (0%)`, + wantOK: true, + checkJSON: func(t *testing.T, data map[string]any) { + t.Helper() + + block := data["block"].(map[string]any) + assert.Equal(t, float64(100), block["number"]) + assert.Equal(t, "0xdeadbeef", block["hash"]) + assert.Equal(t, float64(0), block["tx_count"]) + + timing := data["timing"].(map[string]any) + assert.Equal(t, float64(12), timing["total_ms"]) + }, + }, + { + name: "header with ANSI escape codes", + line: "\x1b[2m2026-05-27T14:00:00.123456Z\x1b[0m \x1b[32m INFO\x1b[0m [METRIC] BLOCK 24358001 0x9f566dc9f8beb533 | 2.500 Ggas/s | 400.00 ms | 200 txs | 1000 Mgas (50%)", + wantOK: true, + checkJSON: func(t *testing.T, data map[string]any) { + t.Helper() + + block := data["block"].(map[string]any) + assert.Equal(t, float64(24358001), block["number"]) + assert.Equal(t, "0x9f566dc9f8beb533", block["hash"]) + assert.Equal(t, float64(1000), block["gas_used_mgas"]) + + throughput := data["throughput"].(map[string]any) + assert.Equal(t, 2.5, throughput["ggas_per_sec"]) + }, + }, + { + name: "phase sub-line is not the header", + line: `2026-05-27T14:00:00.123456Z INFO |- exec: 450.00 ms (80%) << BOTTLENECK`, + wantOK: false, + }, + { + name: "non-pipeline import throughput line is not matched", + line: `2026-05-27T14:00:00.123456Z INFO [METRIC] BLOCK EXECUTION THROUGHPUT (24358000): 1.234 Ggas/s TIME SPENT: 567 ms. Gas Used: 0.700 (93%), #Txs: 150.`, + wantOK: false, + }, + { + name: "block building throughput is not block execution", + line: `2026-05-27T14:00:00.123456Z INFO [METRIC] BLOCK BUILDING THROUGHPUT: 1.234 Gigagas/s TIME SPENT: 567 msecs`, + wantOK: false, + }, + { + name: "unrelated ethrex info log", + line: `2026-05-27T14:00:00.123456Z INFO Initiating blockchain with levm`, + wantOK: false, + }, + { + name: "empty line", + line: "", + wantOK: false, + }, + { + name: "random text", + line: "some random log output that does not match", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := parser.ParseLine(tt.line) + + assert.Equal(t, tt.wantOK, ok) + + if tt.wantOK { + require.NotNil(t, result) + + var parsed map[string]any + err := json.Unmarshal(result, &parsed) + require.NoError(t, err) + + tt.checkJSON(t, parsed) + } else { + assert.Nil(t, result) + } + }) + } +} + +func TestEthrexParser_ClientType(t *testing.T) { + parser := NewEthrexParser() + assert.Equal(t, "ethrex", string(parser.ClientType())) +}