Skip to content
Open
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
5 changes: 5 additions & 0 deletions cmd/evmconnect.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ func run() error {
return err
}

// Emit the block listener metrics into the metrics registry of the manager
if err := c.BlockListener().InitMetrics(ctx, m.MetricsRegistry()); err != nil {
return err
}

// Setup signal handling to cancel the context, which shuts down the API Server
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
Expand Down
1 change: 1 addition & 0 deletions internal/msgs/en_error_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,5 @@ var (
MsgTransactionEstimateTooLargeForBlock = ffe("FF23071", "Gas estimate %s (scaled at %.2f from estimate %s) too large for the current block gas limit %s")
MsgMonitoredHeadLengthInvalid = ffe("FF23072", "Monitored head length must be greater than or equal to 1 value=%d")
MsgUnknownJSONFormatOptionValue = ffe("FF23073", "Unknown value '%s' for JSON formatting option '%s'. Supported values: %s")
MsgMetricsInitFail = ffe("FF23074", "Failed to initialize metrics for subsystem '%s'")
)
19 changes: 19 additions & 0 deletions mocks/ethblocklistenermocks/block_listener.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 22 additions & 3 deletions pkg/ethblocklistener/blocklistener.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/hyperledger-firefly/common/pkg/fftypes"
"github.com/hyperledger-firefly/common/pkg/i18n"
"github.com/hyperledger-firefly/common/pkg/log"
"github.com/hyperledger-firefly/common/pkg/metric"
"github.com/hyperledger-firefly/common/pkg/retry"
"github.com/hyperledger-firefly/common/pkg/wsclient"
"github.com/hyperledger-firefly/evmconnect/internal/msgs"
Expand Down Expand Up @@ -89,6 +90,7 @@ type BlockListener interface {
WaitClosed()
GetBackend() rpcbackend.RPC
UTSetBackend(rpcbackend.RPC)
InitMetrics(ctx context.Context, registry metric.MetricsRegistry) error
}

func toMinimalBlockInfoList(blocks []*ethrpc.BlockInfoJSONRPC) []*ethrpc.MinimalBlockInfo {
Expand Down Expand Up @@ -142,6 +144,11 @@ type blockListener struct {

// headBlockNumber mode: last head value sent on the block listener channel (only written from listenLoop)
currentChainHead uint64

// metrics are optional - only emitted once InitMetrics has been called
metricsLock sync.RWMutex
metrics metric.MetricsManager
metricsLoopDone chan struct{}
}

func NewBlockListener(ctx context.Context, retry *retry.Retry, conf *BlockListenerConfig, httpConf *ffresty.Config, wsConf *wsclient.WSConfig) (bl BlockListener, err error) {
Expand Down Expand Up @@ -307,8 +314,8 @@ func (bl *blockListener) establishBlockHeightWithRetry() error {
})
}

// refreshHighestBlockFromRPC updates highestBlock from eth_blockNumber. Caller must not hold canonicalChainLock.
func (bl *blockListener) refreshHighestBlockFromRPC() (uint64, error) {
// queryBlockHeightFromRPC queries eth_blockNumber and returns the result, without updating any listener state.
func (bl *blockListener) queryBlockHeightFromRPC() (uint64, error) {
var hexBlockHeight ethtypes.HexInteger
rpcErr := bl.backend.CallRPC(bl.ctx, &hexBlockHeight, "eth_blockNumber")
if rpcErr != nil {
Expand Down Expand Up @@ -400,17 +407,20 @@ func (bl *blockListener) listenLoop() {
}

if bl.ChainTrackingMode == ffcapi.ChainTrackingModeLight {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I just check the metric emission hasn't been made specific to light mode

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No - no matter what the metrics are emitted in all modes.

What you're seeing is a metric behavior difference between light mode and full mode - bc light mode does not track a canonical chain, the height it reports for the canonical chain height is the RPC's own block height.

head, err := bl.refreshHighestBlockFromRPC()
head, err := bl.queryBlockHeightFromRPC()
if err != nil {
log.L(bl.ctx).Errorf("Failed to refresh chain head: %s", err)
failCount++
continue
}
// In light mode there is no canonical chain being built, so the head we dispatch to
// consumers is what we report as the canonical height
if head == bl.currentChainHead {
failCount = 0
continue
}
bl.currentChainHead = head
bl.setBlockHeightMetric(metricCanonicalBlockHeight, bl.currentChainHead)
update := &ffcapi.BlockHashEvent{GapPotential: false, Created: fftypes.Now(), HeadBlockNumber: bl.currentChainHead}
bl.consumerMux.Lock()
consumers := make([]*BlockUpdateConsumer, 0, len(bl.consumers))
Expand Down Expand Up @@ -826,4 +836,13 @@ func (bl *blockListener) WaitClosed() {
case <-bl.ctx.Done():
}
}
bl.metricsLock.RLock()
metricsLoopDone := bl.metricsLoopDone
bl.metricsLock.RUnlock()
if metricsLoopDone != nil {
select {
case <-metricsLoopDone:
case <-bl.ctx.Done():
}
}
}
131 changes: 131 additions & 0 deletions pkg/ethblocklistener/blocklistener_metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright © 2026 Kaleido, Inc.
//
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ethblocklistener

import (
"context"
"time"

"github.com/hyperledger-firefly/common/pkg/i18n"
"github.com/hyperledger-firefly/common/pkg/log"
"github.com/hyperledger-firefly/common/pkg/metric"
"github.com/hyperledger-firefly/evmconnect/internal/msgs"
"github.com/hyperledger-firefly/evmconnect/pkg/ethrpc"
"github.com/hyperledger-firefly/transaction-manager/pkg/ffcapi"
)

const (
metricsSubsystem = "blocklistener"

// metricTargetBlockHeight is the block height the endpoint we are connected to reports via eth_blockNumber.
metricTargetBlockHeight = "target_block_height"
// metricCanonicalBlockHeight is the height of the head of the canonical chain this listener is managing,
// built from the block filter / newHeads subscription. It should track the target height very closely.
metricCanonicalBlockHeight = "canonical_block_height"
)

// InitMetrics registers the block height gauges against the supplied registry, and starts the poll loop
// that emits them.
func (bl *blockListener) InitMetrics(ctx context.Context, registry metric.MetricsRegistry) error {
mm, err := registry.NewMetricsManagerForSubsystem(ctx, metricsSubsystem)
if err != nil {
return i18n.WrapError(ctx, err, msgs.MsgMetricsInitFail, metricsSubsystem)
}
mm.NewGaugeMetric(ctx, metricTargetBlockHeight, "The block height reported by the connected node via eth_blockNumber", false)
mm.NewGaugeMetric(ctx, metricCanonicalBlockHeight, "The block height of the head of the canonical chain tracked by the block listener", false)

bl.metricsLock.Lock()
defer bl.metricsLock.Unlock()
bl.metrics = mm
if bl.metricsLoopDone == nil {
bl.metricsLoopDone = make(chan struct{})
go bl.metricsLoop()
}
return nil
}

func (bl *blockListener) getMetrics() metric.MetricsManager {
bl.metricsLock.RLock()
defer bl.metricsLock.RUnlock()
return bl.metrics
}

func (bl *blockListener) setBlockHeightMetric(metricName string, blockHeight uint64) {
mm := bl.getMetrics()
if mm == nil {
return
}
mm.SetGaugeMetric(bl.ctx, metricName, float64(blockHeight), nil)
}

// metricsLoop samples the block heights on the block polling interval. Decoupled from the listener loop.
func (bl *blockListener) metricsLoop() {
defer close(bl.metricsLoopDone)

// Wait for the listen loop to establish the initial block height before making any query of our own.
// As well as avoiding driving JSON/RPC traffic before the listener is running, this ensures the
// WebSocket backend (when configured) has been switched in before we read bl.backend.
select {
case <-bl.initialBlockHeightObtained:
case <-bl.ctx.Done():
return
}

for {
bl.emitChainStateMetrics()
select {
case <-bl.ctx.Done():
return
case <-time.After(bl.BlockPollingInterval):
}
}
}

func (bl *blockListener) emitChainStateMetrics() {
if bl.getMetrics() == nil {
return // never drive any query of the node when metrics are not enabled
}

// The canonical head is free to read - note in light chain tracking mode there is no canonical chain,
// so the listen loop emits the head it dispatches to consumers instead
if bl.ChainTrackingMode != ffcapi.ChainTrackingModeLight {
if canonicalHeight, ok := bl.getCanonicalBlockHeight(); ok {
bl.setBlockHeightMetric(metricCanonicalBlockHeight, canonicalHeight)
}
}

// The target height requires a query of the node - a pure read, no listener state is updated
head, err := bl.queryBlockHeightFromRPC()
if err != nil {
// Purely a metrics query - the listen loop has its own error handling for the chain state
log.L(bl.ctx).Warnf("Failed to query target block height for metrics: %s", err)
return
}
bl.setBlockHeightMetric(metricTargetBlockHeight, head)
}
Comment on lines +88 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@onelapahead can you elaborate on the thinking behind here? The metrics-emitting logic does functional logic (updates the chain head), rather than purely capturing metrics.

Have you considered making the metrics-emitting logic do a no-op or emit an "unavailable/error" status metric when the main block height setting loop is in an unready state?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is not the case nor intent - getCanonicalBlockHeight() acquires a RLock to read the listener's state but otherwise does not update anything as I understand it.

I've renamed refreshHighestBlockFromRPC() bc that was misleading, its named queryBlockHeightFromRPC and its just purely an eth_blokcNumber call, but it doesn't change any listener state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@onelapahead Thanks for the renaming.

So what was added in the PR is a separate goroutine that

  1. runs on the same timer as the main listener goroutine.
  2. doing a separate & extra eth_blockNumber call on the same timer

That approach has its merit, which is very easy to understand as a small patch, but doesn't feel like the most efficient way of capturing the diagnostic information. Also, lacks the next level of granularity to pin down the problem for diagnosis, because the metrics are not associated with any variable that's driving the functional logic.

Breaking down the situation you are trying to resolve, I'd give the following suggestions:

  1. is the target block height not incrementing (node out of sync) - depending on the underlying consensus/EVM implementation, this might indicate the node is failing to sync the chain.

it's a node running problem, metrics and alert should be added in the JSON-RPC node.

  1. are the block listener canonical height and the target height out of sync within some toleration/expectation - this would either indicate the endpoint has a bad filter/subscription and is failing to notify the listener of new blocks, or the listener itself is somehow failing to track the chain otherwise.

metrics should be added to track those failures. e.g. checkAndSetHighestBlock (and/orqueryBlockHeightFromRPC) should emit failure metrics and gauge metrics for new head, so when the head stays stale, it's obvious. It can be used as a trigger to escalate to the JSON-RPC node provider ops to check their metrics as well if the metric is from queryBlockHeightFromRPC.

  1. if we are able to monitor the chain height itself via other means (a synced node we're monitoring, a block explorer, etc.), we can detect the difference of the target/canonical with the monitor to detect any lag.

this should be achieveable by adding metrics to checkAndSetHighestBlock and queryBlockHeightFromRPC

So I'd suggest replacing the new goroutine with metrics in checkAndSetHighestBlock and queryBlockHeightFromRPC. If you feel there is a need to do queryBlockHeightFromRPC in full-tracking mode, we could think about where to add it in the existing logic to improve the function logic. (Suggest a separate PR for this to make the current PR single-purposed)


// getCanonicalBlockHeight returns the head of the in-memory canonical chain, with ok false until we
// have indexed a block.
func (bl *blockListener) getCanonicalBlockHeight() (uint64, bool) {
bl.canonicalChainLock.RLock()
defer bl.canonicalChainLock.RUnlock()
back := bl.canonicalChain.Back()
if back == nil || back.Value == nil {
return 0, false
}
return back.Value.(*ethrpc.BlockInfoJSONRPC).Number.Uint64(), true
}
Loading