From 332d8766ff9369d9adff503cc6ea0262f719f1f0 Mon Sep 17 00:00:00 2001 From: auricom <27022259+auricom@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:16:22 +0200 Subject: [PATCH 1/4] refactor(metrics): remove EVMETRICS_DEBUG and stdlib log.Printf in favour of zerolog Drop isDebugEnabled() and the log.Printf branches in RecordSubmissionAttempt and RecordSubmissionDaHeight; the verifier already logs these events through zerolog. Document --verbose as the single way to get debug output. Fixes #50 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 16 +--------------- pkg/metrics/metrics.go | 33 --------------------------------- 2 files changed, 1 insertion(+), 48 deletions(-) diff --git a/README.md b/README.md index 83ff0da..f02ec0f 100644 --- a/README.md +++ b/README.md @@ -328,18 +328,4 @@ When `--balance.addresses` and `--balance.consensus-rpc-urls` are provided: ## Debug Logging -There are two independent debug logging mechanisms: - -- `--verbose` flag: sets the application log level to debug. Per-block and per-poll success messages (block verification, drift polls, balance checks, JSON-RPC health checks) are logged at debug level, so use `--verbose` to see them. Warnings and errors are always logged. -- `EVMETRICS_DEBUG=true` environment variable: logs DA submission details from the metrics package. - -**Use cases**: Troubleshoot submission failures, verify submission flow. - -**`EVMETRICS_DEBUG` logs include**: Successful/failed submission attempts and DA height updates. - -**Example**: -```bash -# Enable debug logging -export EVMETRICS_DEBUG=true -./ev-metrics monitor --header-namespace testnet_header --data-namespace testnet_data -``` +Use the `--verbose` flag to set the application log level to debug. It is the single way to get debug output: per-block, per-poll (drift, balance, JSON-RPC health checks) and per-verification success messages. Warnings and errors are always logged. diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index e62843a..7bd8e4f 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -2,10 +2,7 @@ package metrics import ( "fmt" - "log" - "os" "sort" - "strings" "sync" "sync/atomic" "time" @@ -102,11 +99,6 @@ type Metrics struct { ranges map[string][]*blockRange // key: blobType -> sorted slice of ranges } -// isDebugEnabled returns true if debug logging is enabled via environment variable -func isDebugEnabled() bool { - return strings.ToLower(os.Getenv("EVMETRICS_DEBUG")) == "true" -} - type blockRange struct { start uint64 end uint64 @@ -437,13 +429,6 @@ func (m *Metrics) RecordSubmissionAttempt(chainID, submissionType string, succes if success { m.LastSuccessfulSubmissionTime.WithLabelValues(chainID, submissionType).Set(float64(now.Unix())) - if isDebugEnabled() { - log.Printf("DEBUG: Successful submission - chain: %s, type: %s, timestamp: %d", chainID, submissionType, now.Unix()) - } - } else { - if isDebugEnabled() { - log.Printf("DEBUG: Failed submission attempt - chain: %s, type: %s, timestamp: %d", chainID, submissionType, now.Unix()) - } } } @@ -456,15 +441,6 @@ func (m *Metrics) RecordSubmissionDaHeight(chainID, submissionType string, daHei if daHeight > m.latestHeaderDaHeight { m.latestHeaderDaHeight = daHeight m.SubmissionDaHeight.WithLabelValues(chainID, "header").Set(float64(daHeight)) - // Debug log when submission DA height is recorded - if isDebugEnabled() { - log.Printf("DEBUG: Recorded header submission DA height - chain: %s, height: %d", chainID, daHeight) - } - } else { - // Debug log when DA height is not higher than previous - if isDebugEnabled() { - log.Printf("DEBUG: Header DA height %d not higher than previous %d for chain %s", daHeight, m.latestHeaderDaHeight, chainID) - } } return } @@ -473,15 +449,6 @@ func (m *Metrics) RecordSubmissionDaHeight(chainID, submissionType string, daHei if daHeight > m.latestDataDaHeight { m.latestDataDaHeight = daHeight m.SubmissionDaHeight.WithLabelValues(chainID, "data").Set(float64(daHeight)) - // Debug log when submission DA height is recorded - if isDebugEnabled() { - log.Printf("DEBUG: Recorded data submission DA height - chain: %s, height: %d", chainID, daHeight) - } - } else { - // Debug log when DA height is not higher than previous - if isDebugEnabled() { - log.Printf("DEBUG: Data DA height %d not higher than previous %d for chain %s", daHeight, m.latestDataDaHeight, chainID) - } } } } From 64d230b84fa38af888d557473b62685a33831a74 Mon Sep 17 00:00:00 2001 From: auricom <27022259+auricom@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:17:07 +0200 Subject: [PATCH 2/4] refactor: small cleanups in metrics, evnode and evm clients - metrics: collapse the header/data branches of RecordSubmissionDaHeight into a per-type map of the highest recorded DA height - evnode: delete unused GetState and GetGenesisDaHeight - evm: measure the health check RTT after the body is read and fail on a JSON-RPC error object; categorize it as rpc_error; add client tests - README: submission attempts/failures are counted per blob type per verification round, not per RPC attempt Fixes #51 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +-- internal/clients/evm/client.go | 18 ++++++++-- internal/clients/evm/client_test.go | 53 +++++++++++++++++++++++++++++ internal/clients/evnode/client.go | 25 -------------- pkg/metrics/metrics.go | 19 ++++------- pkg/utils/errors.go | 2 ++ 6 files changed, 78 insertions(+), 43 deletions(-) create mode 100644 internal/clients/evm/client_test.go diff --git a/README.md b/README.md index f02ec0f..c545827 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,12 @@ Blocks without transactions have only their header blob verified (`type="header" ### `ev_metrics_submission_attempts_total` - **Type**: Counter - **Labels**: `chain_id`, `type` -- **Description**: Total number of DA submission attempts (both successful and failed) +- **Description**: Total number of DA submission attempts (both successful and failed). Counted once per blob type per verification round, not once per RPC attempt. A block whose retries run out and which is re-verified after the cooldown starts a new round. ### `ev_metrics_submission_failures_total` - **Type**: Counter - **Labels**: `chain_id`, `type` -- **Description**: Total number of failed DA submission attempts +- **Description**: Total number of failed DA submission attempts. Counted once per blob type per verification round, not once per RPC attempt. A block whose retries run out and which is re-verified after the cooldown starts a new round. ### `ev_metrics_last_submission_attempt_time` - **Type**: Gauge diff --git a/internal/clients/evm/client.go b/internal/clients/evm/client.go index 1991b47..e4348e3 100644 --- a/internal/clients/evm/client.go +++ b/internal/clients/evm/client.go @@ -73,17 +73,29 @@ func (c *Client) HealthCheckRequest(ctx context.Context) (time.Duration, error) } }() - duration := time.Since(startTime) - // Read the response to ensure full RTT - _, err = io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return 0, fmt.Errorf("failed to read response: %w", err) } + duration := time.Since(startTime) if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } + var rpcResp struct { + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + return 0, fmt.Errorf("failed to decode response: %w", err) + } + if rpcResp.Error != nil { + return 0, fmt.Errorf("JSON-RPC error: code %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + return duration, nil } diff --git a/internal/clients/evm/client_test.go b/internal/clients/evm/client_test.go new file mode 100644 index 0000000..e58cb25 --- /dev/null +++ b/internal/clients/evm/client_test.go @@ -0,0 +1,53 @@ +package evm + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" +) + +func newTestClient(srv *httptest.Server) *Client { + return &Client{rpcURL: srv.URL, httpClient: srv.Client(), logger: zerolog.Nop()} +} + +func TestHealthCheckRequest_SuccessMeasuresFullBody(t *testing.T) { + const delay = 100 * time.Millisecond + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,`)) + w.(http.Flusher).Flush() + time.Sleep(delay) + _, _ = w.Write([]byte(`"result":"0x10"}`)) + })) + defer srv.Close() + + d, err := newTestClient(srv).HealthCheckRequest(context.Background()) + require.NoError(t, err) + require.GreaterOrEqual(t, d, delay) +} + +func TestHealthCheckRequest_UnexpectedStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := newTestClient(srv).HealthCheckRequest(context.Background()) + require.ErrorContains(t, err, "unexpected status code: 500") +} + +func TestHealthCheckRequest_JSONRPCError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}`)) + })) + defer srv.Close() + + _, err := newTestClient(srv).HealthCheckRequest(context.Background()) + require.ErrorContains(t, err, "-32601") + require.ErrorContains(t, err, "method not found") +} diff --git a/internal/clients/evnode/client.go b/internal/clients/evnode/client.go index 00de0ef..061161b 100644 --- a/internal/clients/evnode/client.go +++ b/internal/clients/evnode/client.go @@ -10,7 +10,6 @@ import ( "github.com/evstack/ev-node/types/pb/evnode/v1/v1connect" "github.com/rs/zerolog" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/emptypb" ) type Client struct { @@ -28,18 +27,6 @@ func NewClient(addr string, logger zerolog.Logger) *Client { } } -// GetState queries the current state including latest block height -func (c *Client) GetState(ctx context.Context) (*evnode.GetStateResponse, error) { - req := connect.NewRequest(&emptypb.Empty{}) - - resp, err := c.storeClient.GetState(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to get state: %w", err) - } - - return resp.Msg, nil -} - type GetBlockResult struct { HeaderDaHeight uint64 DataDaHeight uint64 @@ -96,15 +83,3 @@ func (c *Client) GetBlockWithBlobs(ctx context.Context, height uint64) (*GetBloc DataBlob: dataBlob, }, nil } - -// GetGenesisDaHeight retrieves the DA height at which the first block was included -func (c *Client) GetGenesisDaHeight(ctx context.Context) (uint64, error) { - req := connect.NewRequest(&emptypb.Empty{}) - - resp, err := c.storeClient.GetGenesisDaHeight(ctx, req) - if err != nil { - return 0, fmt.Errorf("failed to get genesis DA height: %w", err) - } - - return resp.Msg.Height, nil -} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 7bd8e4f..b83f96c 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -89,8 +89,7 @@ type Metrics struct { lastHeaderReceived atomic.Int64 // unix nanos // internal tracking to ensure we only record increasing DA heights - latestHeaderDaHeight uint64 - latestDataDaHeight uint64 + latestDaHeight map[string]uint64 // key: submission type ("header" or "data") // internal tracking for block time calculation (uses arrival time for ms precision) lastBlockArrivalTime map[string]time.Time // key: chainID @@ -405,6 +404,7 @@ func NewWithRegistry(namespace string, registerer prometheus.Registerer) *Metric []string{"chain_id"}, ), ranges: make(map[string][]*blockRange), + latestDaHeight: make(map[string]uint64), lastBlockArrivalTime: make(map[string]time.Time), } @@ -437,19 +437,12 @@ func (m *Metrics) RecordSubmissionDaHeight(chainID, submissionType string, daHei m.mu.Lock() defer m.mu.Unlock() - if submissionType == "header" { - if daHeight > m.latestHeaderDaHeight { - m.latestHeaderDaHeight = daHeight - m.SubmissionDaHeight.WithLabelValues(chainID, "header").Set(float64(daHeight)) - } + if submissionType != "header" && submissionType != "data" { return } - - if submissionType == "data" { - if daHeight > m.latestDataDaHeight { - m.latestDataDaHeight = daHeight - m.SubmissionDaHeight.WithLabelValues(chainID, "data").Set(float64(daHeight)) - } + if daHeight > m.latestDaHeight[submissionType] { + m.latestDaHeight[submissionType] = daHeight + m.SubmissionDaHeight.WithLabelValues(chainID, submissionType).Set(float64(daHeight)) } } diff --git a/pkg/utils/errors.go b/pkg/utils/errors.go index 52e4598..ce29a05 100644 --- a/pkg/utils/errors.go +++ b/pkg/utils/errors.go @@ -27,6 +27,8 @@ func CategorizeError(err error) string { return "connection_failed" case strings.Contains(errStr, "failed to get block number"): return "rpc_error" + case strings.Contains(errStr, "JSON-RPC error"): + return "rpc_error" case strings.Contains(errStr, "unexpected status code"): return "http_error" case strings.Contains(errStr, "failed to send request"): From eb6dca4b7d7e6fd908bb98598f697bebd76b93bf Mon Sep 17 00:00:00 2001 From: auricom <27022259+auricom@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:19:50 +0200 Subject: [PATCH 3/4] build(ci): static non-root Docker image, pinned lint, race tests, govulncheck, Node 24 actions - Dockerfile: CGO-free static build cross-compiled on the build platform, distroless static nonroot runtime pinned by digest, numeric USER - add .dockerignore - make test runs with -race - pin golangci-lint v2.15.0 (golangci-lint-action v9) - move actions to Node 24 majors; drop QEMU (no arm64 binaries are run) - add govulncheck workflow allowing only GO-2026-5932 Fixes #52 Co-Authored-By: Claude Sonnet 5.5 --- .dockerignore | 9 ++++++++ .github/workflows/docker.yml | 13 +++++------- .github/workflows/govulncheck.yml | 34 +++++++++++++++++++++++++++++++ .github/workflows/lint.yml | 8 ++++---- .github/workflows/test.yml | 4 ++-- Dockerfile | 20 ++++++++---------- Makefile | 2 +- 7 files changed, 64 insertions(+), 26 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/govulncheck.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d8b1999 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.gitignore +*.md +ev-metrics +*.test +*.out +Dockerfile +.dockerignore diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9ca8bd9..3bcf3ee 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,16 +21,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: actions/checkout@v7 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -38,7 +35,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -49,7 +46,7 @@ jobs: type=sha - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..3b83873 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,34 @@ +name: govulncheck + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: '0 6 * * 1' + +env: + # reachable vulnerability with no upstream fix yet; delete once x/crypto ships a fix + ALLOWED_VULN: GO-2026-5932 + +jobs: + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: 'go.mod' + + - name: Run govulncheck + run: | + go run golang.org/x/vuln/cmd/govulncheck@latest -format json ./... \ + | jq -e -s --arg allowed "$ALLOWED_VULN" \ + '[.[] | select(.finding != null and .finding.trace[0].function != null) | .finding.osv] | unique - [$allowed] | length == 0' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index af56aac..574c2a1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,14 +14,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: go-version-file: 'go.mod' - name: Run golangci-lint - uses: golangci/golangci-lint-action@v8 + uses: golangci/golangci-lint-action@v9 with: - version: latest + version: v2.15.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40b3671..86440aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: go-version-file: 'go.mod' diff --git a/Dockerfile b/Dockerfile index d9b8089..8d09f84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,9 @@ # build stage -FROM golang:1.26-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder -WORKDIR /build +ARG TARGETOS TARGETARCH -# install build dependencies -RUN apk add --no-cache git gcc musl-dev +WORKDIR /build # copy go mod files COPY go.mod go.sum ./ @@ -13,17 +12,16 @@ RUN go mod download # copy source code COPY . . -# build binary -RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o ev-metrics . +# build static binary for the target platform +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o ev-metrics . -# final stage -FROM alpine:latest - -RUN apk --no-cache add ca-certificates +# final stage: includes CA certificates and a nonroot user (uid 65532) +FROM gcr.io/distroless/static:nonroot@sha256:516757106de35205efb70b91ba03f6f1298424a0678c603255cf2852a7000087 WORKDIR /app -# copy binary from builder COPY --from=builder /build/ev-metrics . +USER 65532:65532 + ENTRYPOINT ["/app/ev-metrics"] diff --git a/Makefile b/Makefile index ef8601c..acaa68b 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ lint-fix: # run all tests test: @echo "Running tests..." - go test -v ./... + go test -race -v ./... # build the binary build: From 53be1c39caf9135ca5335c6c10d891a2706052ce Mon Sep 17 00:00:00 2001 From: auricom <27022259+auricom@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:29:53 +0200 Subject: [PATCH 4/4] ci(govulncheck): scan with the latest Go release instead of the go.mod minimum The job installed Go 1.26.0 from go.mod, whose standard library has about 20 vulnerabilities fixed in later 1.26.x patches, so the filter always failed. A local run missed this because GOTOOLCHAIN=auto silently switched to a newer toolchain. Refs #52 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/govulncheck.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 3b83873..d314145 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -25,7 +25,11 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version-file: 'go.mod' + # scan with the latest Go release, not the go.mod minimum (1.26.0): the minimum has + # many already-fixed standard library vulnerabilities that are not about this code. + # The Docker image builds on golang:1.26-alpine, which tracks the latest patch. + go-version: 'stable' + check-latest: true - name: Run govulncheck run: |