Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 76 additions & 6 deletions pkg/blocklog/ethrex.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,98 @@ 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<hash> | 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 &ethrexParser{}
}

// 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
}
146 changes: 146 additions & 0 deletions pkg/blocklog/ethrex_test.go
Original file line number Diff line number Diff line change
@@ -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()))
}