Skip to content

Commit 10e853d

Browse files
authored
Merge pull request #216 from kaleido-io/blocklistener-blockheight
feat(blocklistener): Block height metrics for canonical chain vs target node
2 parents cca3e67 + ae80654 commit 10e853d

6 files changed

Lines changed: 386 additions & 9 deletions

File tree

cmd/evmconnect.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ func run() error {
110110
return err
111111
}
112112

113+
// Emit the block listener metrics into the metrics registry of the manager
114+
if err := c.BlockListener().InitMetrics(ctx, m.MetricsRegistry()); err != nil {
115+
return err
116+
}
117+
113118
// Setup signal handling to cancel the context, which shuts down the API Server
114119
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
115120
go func() {

internal/msgs/en_error_messages.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,5 @@ var (
9191
MsgTransactionEstimateTooLargeForBlock = ffe("FF23071", "Gas estimate %s (scaled at %.2f from estimate %s) too large for the current block gas limit %s")
9292
MsgMonitoredHeadLengthInvalid = ffe("FF23072", "Monitored head length must be greater than or equal to 1 value=%d")
9393
MsgUnknownJSONFormatOptionValue = ffe("FF23073", "Unknown value '%s' for JSON formatting option '%s'. Supported values: %s")
94+
MsgMetricsInitFail = ffe("FF23074", "Failed to initialize metrics for subsystem '%s'")
9495
)

mocks/ethblocklistenermocks/block_listener.go

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/ethblocklistener/blocklistener.go

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/hyperledger-firefly/common/pkg/fftypes"
2828
"github.com/hyperledger-firefly/common/pkg/i18n"
2929
"github.com/hyperledger-firefly/common/pkg/log"
30+
"github.com/hyperledger-firefly/common/pkg/metric"
3031
"github.com/hyperledger-firefly/common/pkg/retry"
3132
"github.com/hyperledger-firefly/common/pkg/wsclient"
3233
"github.com/hyperledger-firefly/evmconnect/internal/msgs"
@@ -89,6 +90,7 @@ type BlockListener interface {
8990
WaitClosed()
9091
GetBackend() rpcbackend.RPC
9192
UTSetBackend(rpcbackend.RPC)
93+
InitMetrics(ctx context.Context, registry metric.MetricsRegistry) error
9294
}
9395

9496
func toMinimalBlockInfoList(blocks []*ethrpc.BlockInfoJSONRPC) []*ethrpc.MinimalBlockInfo {
@@ -142,6 +144,10 @@ type blockListener struct {
142144

143145
// headBlockNumber mode: last head value sent on the block listener channel (only written from listenLoop)
144146
currentChainHead uint64
147+
148+
// metrics are optional - only emitted once InitMetrics has been called
149+
metricsLock sync.RWMutex
150+
metrics metric.MetricsManager
145151
}
146152

147153
func NewBlockListener(ctx context.Context, retry *retry.Retry, conf *BlockListenerConfig, httpConf *ffresty.Config, wsConf *wsclient.WSConfig) (bl BlockListener, err error) {
@@ -295,26 +301,28 @@ func (bl *blockListener) establishBlockHeightWithRetry() error {
295301
}
296302

297303
// Now get the block height
298-
var hexBlockHeight ethtypes.HexInteger
299-
rpcErr := bl.backend.CallRPC(bl.ctx, &hexBlockHeight, "eth_blockNumber")
300-
if rpcErr != nil {
301-
log.L(bl.ctx).Warnf("Block height could not be obtained: %s", rpcErr.Message)
302-
return true, rpcErr.Error()
304+
head, err := bl.queryBlockHeightFromRPC()
305+
if err != nil {
306+
log.L(bl.ctx).Warnf("Block height could not be obtained: %s", err)
307+
return true, err
303308
}
304309

305-
bl.setHighestBlock(hexBlockHeight.BigInt().Uint64())
310+
bl.setHighestBlock(head)
306311
return false, nil
307312
})
308313
}
309314

310-
// refreshHighestBlockFromRPC updates highestBlock from eth_blockNumber. Caller must not hold canonicalChainLock.
311-
func (bl *blockListener) refreshHighestBlockFromRPC() (uint64, error) {
315+
// queryBlockHeightFromRPC queries eth_blockNumber and returns the result, without updating any listener
316+
// state. Caller must not hold canonicalChainLock. The height the node reports is recorded on the target block height gauge.
317+
func (bl *blockListener) queryBlockHeightFromRPC() (uint64, error) {
312318
var hexBlockHeight ethtypes.HexInteger
313319
rpcErr := bl.backend.CallRPC(bl.ctx, &hexBlockHeight, "eth_blockNumber")
314320
if rpcErr != nil {
321+
bl.incPollFailureMetric("eth_blockNumber")
315322
return 0, rpcErr.Error()
316323
}
317324
head := hexBlockHeight.BigInt().Uint64()
325+
bl.setBlockHeightMetric(metricTargetBlockHeight, head)
318326
return head, nil
319327
}
320328

@@ -367,10 +375,17 @@ func (bl *blockListener) listenLoop() {
367375
}
368376
}
369377

378+
// In full chain tracking mode, the loop below never queries the height the node reports, so we refresh
379+
// it here for the target metric. Done ahead of the filter calls.
380+
if bl.ChainTrackingMode != ffcapi.ChainTrackingModeLight {
381+
bl.refreshTargetBlockHeightMetric()
382+
}
383+
370384
if filter == "" {
371385
err := bl.backend.CallRPC(bl.ctx, &filter, "eth_newBlockFilter")
372386
if err != nil {
373387
log.L(bl.ctx).Errorf("Failed to establish new block filter: %s", err.Message)
388+
bl.incPollFailureMetric("eth_newBlockFilter")
374389
failCount++
375390
continue
376391
}
@@ -393,24 +408,28 @@ func (bl *blockListener) listenLoop() {
393408
gapPotential = true
394409
}
395410
log.L(bl.ctx).Errorf("Failed to query block filter changes: %s", rpcErr.Message)
411+
bl.incPollFailureMetric("eth_getFilterChanges")
396412
failCount++
397413
continue
398414
}
399415
log.L(bl.ctx).Debugf("Block filter received new block hashes: %+v", blockHashes)
400416
}
401417

402418
if bl.ChainTrackingMode == ffcapi.ChainTrackingModeLight {
403-
head, err := bl.refreshHighestBlockFromRPC()
419+
head, err := bl.queryBlockHeightFromRPC()
404420
if err != nil {
405421
log.L(bl.ctx).Errorf("Failed to refresh chain head: %s", err)
406422
failCount++
407423
continue
408424
}
425+
// In light mode there is no canonical chain being built, so the head we dispatch to
426+
// consumers is what we report as the canonical height
409427
if head == bl.currentChainHead {
410428
failCount = 0
411429
continue
412430
}
413431
bl.currentChainHead = head
432+
bl.setBlockHeightMetric(metricCanonicalBlockHeight, bl.currentChainHead)
414433
update := &ffcapi.BlockHashEvent{GapPotential: false, Created: fftypes.Now(), HeadBlockNumber: bl.currentChainHead}
415434
bl.consumerMux.Lock()
416435
consumers := make([]*BlockUpdateConsumer, 0, len(bl.consumers))
@@ -778,6 +797,7 @@ func (bl *blockListener) GetHeadBlockNumber(_ context.Context) uint64 {
778797
}
779798

780799
func (bl *blockListener) setHighestBlock(block uint64) {
800+
defer bl.setBlockHeightMetric(metricCanonicalBlockHeight, block)
781801
bl.canonicalChainLock.Lock()
782802
defer bl.canonicalChainLock.Unlock()
783803
bl.highestBlock = block
@@ -793,6 +813,9 @@ func (bl *blockListener) checkAndSetHighestBlock(bi *ethrpc.BlockInfoJSONRPC) {
793813
bl.highestBlock = block
794814
bl.highestBlockSet = true
795815
bl.headBlockInfo = bi
816+
// The gauge is bound to the same variable GetHighestBlock reports to event streams, so it is the
817+
// head we are actually tracking rather than a separate sample of it.
818+
bl.setBlockHeightMetric(metricCanonicalBlockHeight, block)
796819
} else if block == bl.highestBlock {
797820
// Height already known from eth_blockNumber. Store the first full block at that height.
798821
bl.headBlockInfo = bi
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright © 2026 Kaleido, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
package ethblocklistener
18+
19+
import (
20+
"context"
21+
22+
"github.com/hyperledger-firefly/common/pkg/i18n"
23+
"github.com/hyperledger-firefly/common/pkg/log"
24+
"github.com/hyperledger-firefly/common/pkg/metric"
25+
"github.com/hyperledger-firefly/evmconnect/internal/msgs"
26+
)
27+
28+
const (
29+
metricsSubsystem = "blocklistener"
30+
31+
// metricTargetBlockHeight is the block height the endpoint we are connected to reports via eth_blockNumber.
32+
// Emitted from queryBlockHeightFromRPC, so it is always the value we last received from the node.
33+
metricTargetBlockHeight = "target_block_height"
34+
// metricCanonicalBlockHeight is the head of the chain this listener is tracking - in full chain tracking
35+
// mode the head of the in-memory canonical chain built from the block filter / newHeads subscription,
36+
// and in light mode the head we dispatch to consumers. It should track the target height very closely.
37+
metricCanonicalBlockHeight = "canonical_block_height"
38+
// metricPollFailures counts the JSON/RPC polls the listen loop makes that failed, labelled by method.
39+
metricPollFailures = "poll_failures_total"
40+
metricLabelPollFailures = "method"
41+
)
42+
43+
// InitMetrics registers the block listener metrics against the supplied registry.
44+
func (bl *blockListener) InitMetrics(ctx context.Context, registry metric.MetricsRegistry) error {
45+
mm, err := registry.NewMetricsManagerForSubsystem(ctx, metricsSubsystem)
46+
if err != nil {
47+
return i18n.WrapError(ctx, err, msgs.MsgMetricsInitFail, metricsSubsystem)
48+
}
49+
mm.NewGaugeMetric(ctx, metricTargetBlockHeight, "The block height reported by the connected node via eth_blockNumber", false)
50+
mm.NewGaugeMetric(ctx, metricCanonicalBlockHeight, "The block height of the head of the chain tracked by the block listener", false)
51+
mm.NewCounterMetricWithLabels(ctx, metricPollFailures, "The number of block listener JSON/RPC polls that have failed, by method", []string{metricLabelPollFailures}, false)
52+
53+
bl.metricsLock.Lock()
54+
defer bl.metricsLock.Unlock()
55+
bl.metrics = mm
56+
return nil
57+
}
58+
59+
func (bl *blockListener) getMetrics() metric.MetricsManager {
60+
bl.metricsLock.RLock()
61+
defer bl.metricsLock.RUnlock()
62+
return bl.metrics
63+
}
64+
65+
func (bl *blockListener) setBlockHeightMetric(metricName string, blockHeight uint64) {
66+
mm := bl.getMetrics()
67+
if mm == nil {
68+
return
69+
}
70+
mm.SetGaugeMetric(bl.ctx, metricName, float64(blockHeight), nil)
71+
}
72+
73+
func (bl *blockListener) incPollFailureMetric(method string) {
74+
mm := bl.getMetrics()
75+
if mm == nil {
76+
return
77+
}
78+
mm.IncCounterMetricWithLabels(bl.ctx, metricPollFailures, map[string]string{metricLabelPollFailures: method}, nil)
79+
}
80+
81+
// refreshTargetBlockHeightMetric queries the node for the height it reports, purely so the target gauge
82+
// stays current. Only needed in full chain tracking mode.
83+
func (bl *blockListener) refreshTargetBlockHeightMetric() {
84+
if bl.getMetrics() == nil {
85+
return // never drive any query of the node when metrics are not enabled
86+
}
87+
if _, err := bl.queryBlockHeightFromRPC(); err != nil {
88+
// Diagnostic only - the failure is recorded on the query failure counter, and the listen loop
89+
// has its own error handling for the chain state
90+
log.L(bl.ctx).Warnf("Failed to refresh target block height: %s", err)
91+
}
92+
}

0 commit comments

Comments
 (0)