Skip to content

Commit 44fee3d

Browse files
authored
fix: evm-single sequencer metrics (#2382)
## Overview Sequencer specific metrics (GasPrice, LastBlobSize, TransactionStatus, TransactionStatus, IncludedBlockHeight) were not displayed on the prometheus metrics endpoint. Only rollkit_sequencer_* (block_size, height, latest_block_height,num_txs,total_txs) were displayed. This PR update the evm-single run command to include missing metrics. ```bash curl -s localhost:26660/metrics | grep -i pending # HELP sequencer_num_pending_blocks The number of pending blocks for DA submission. # TYPE sequencer_num_pending_blocks gauge sequencer_num_pending_blocks{chain_id="rollkit-test"} 7 ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced metrics reporting for sequencer operations, including updates after data availability submissions and block height changes. - Introduced a public method for recording sequencer metrics, providing more detailed tracking of gas price, blob size, transaction status, pending blocks, and block heights. - Added a standardized interface to unify metrics recording across components. - **Tests** - Added comprehensive tests to verify metrics recording during data availability submissions and block height increments. - Introduced mocks and test coverage to ensure metrics integration behaves correctly under various scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a6a3a94 commit 44fee3d

8 files changed

Lines changed: 293 additions & 5 deletions

File tree

apps/evm/single/cmd/run.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ var RunCmd = &cobra.Command{
6060
return err
6161
}
6262

63-
singleMetrics, err := single.NopMetrics()
64-
if err != nil {
65-
return err
66-
}
63+
singleMetrics, err := single.DefaultMetricsProvider(nodeConfig.Instrumentation.IsPrometheusEnabled())(nodeConfig.ChainID)
64+
if err != nil {
65+
return err
66+
}
6767

6868
sequencer, err := single.NewSequencer(
6969
context.Background(),

block/da_includer.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"encoding/binary"
66
"fmt"
7+
8+
coreda "github.com/rollkit/rollkit/core/da"
79
)
810

911
// DAIncluderLoop is responsible for advancing the DAIncludedHeight by checking if blocks after the current height
@@ -63,5 +65,11 @@ func (m *Manager) incrementDAIncludedHeight(ctx context.Context) error {
6365
if !m.daIncludedHeight.CompareAndSwap(currentHeight, newHeight) {
6466
return fmt.Errorf("failed to set DA included height: %d", newHeight)
6567
}
68+
69+
// Update sequencer metrics if the sequencer supports it
70+
if seq, ok := m.sequencer.(MetricsRecorder); ok {
71+
seq.RecordMetrics(m.gasPrice, 0, coreda.StatusSuccess, m.pendingHeaders.numPendingHeaders(), newHeight)
72+
}
73+
6674
return nil
6775
}

block/da_includer_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"github.com/stretchr/testify/assert"
1111
"github.com/stretchr/testify/mock"
1212

13+
coreda "github.com/rollkit/rollkit/core/da"
14+
"github.com/rollkit/rollkit/core/sequencer"
1315
"github.com/rollkit/rollkit/pkg/cache"
1416
"github.com/rollkit/rollkit/test/mocks"
1517
"github.com/rollkit/rollkit/types"
@@ -369,6 +371,83 @@ func TestDAIncluderLoop_DoesNotAdvanceWhenDataHashIsEmptyAndHeaderNotDAIncluded(
369371
store.AssertExpectations(t)
370372
}
371373

374+
// MockSequencerWithMetrics is a mock sequencer that implements MetricsRecorder interface
375+
type MockSequencerWithMetrics struct {
376+
mock.Mock
377+
}
378+
379+
func (m *MockSequencerWithMetrics) RecordMetrics(gasPrice float64, blobSize uint64, statusCode coreda.StatusCode, numPendingBlocks uint64, includedBlockHeight uint64) {
380+
m.Called(gasPrice, blobSize, statusCode, numPendingBlocks, includedBlockHeight)
381+
}
382+
383+
// Implement the Sequencer interface methods
384+
func (m *MockSequencerWithMetrics) SubmitBatchTxs(ctx context.Context, req sequencer.SubmitBatchTxsRequest) (*sequencer.SubmitBatchTxsResponse, error) {
385+
args := m.Called(ctx, req)
386+
return args.Get(0).(*sequencer.SubmitBatchTxsResponse), args.Error(1)
387+
}
388+
389+
func (m *MockSequencerWithMetrics) GetNextBatch(ctx context.Context, req sequencer.GetNextBatchRequest) (*sequencer.GetNextBatchResponse, error) {
390+
args := m.Called(ctx, req)
391+
return args.Get(0).(*sequencer.GetNextBatchResponse), args.Error(1)
392+
}
393+
394+
func (m *MockSequencerWithMetrics) VerifyBatch(ctx context.Context, req sequencer.VerifyBatchRequest) (*sequencer.VerifyBatchResponse, error) {
395+
args := m.Called(ctx, req)
396+
return args.Get(0).(*sequencer.VerifyBatchResponse), args.Error(1)
397+
}
398+
399+
// TestIncrementDAIncludedHeight_WithMetricsRecorder verifies that incrementDAIncludedHeight calls RecordMetrics
400+
// when the sequencer implements the MetricsRecorder interface (covers lines 73-74).
401+
func TestIncrementDAIncludedHeight_WithMetricsRecorder(t *testing.T) {
402+
t.Parallel()
403+
m, store, exec, logger := newTestManager(t)
404+
startDAIncludedHeight := uint64(4)
405+
expectedDAIncludedHeight := startDAIncludedHeight + 1
406+
m.daIncludedHeight.Store(startDAIncludedHeight)
407+
408+
// Set up mock sequencer with metrics
409+
mockSequencer := new(MockSequencerWithMetrics)
410+
m.sequencer = mockSequencer
411+
m.gasPrice = 1.5 // Set a test gas price
412+
413+
// Mock the store calls needed for PendingHeaders initialization
414+
// First, clear the existing Height mock from newTestManager
415+
store.ExpectedCalls = nil
416+
417+
// Create a byte array representing lastSubmittedHeight = 4
418+
lastSubmittedBytes := make([]byte, 8)
419+
binary.LittleEndian.PutUint64(lastSubmittedBytes, startDAIncludedHeight)
420+
421+
store.On("GetMetadata", mock.Anything, LastSubmittedHeightKey).Return(lastSubmittedBytes, nil).Maybe() // For pendingHeaders init
422+
store.On("Height", mock.Anything).Return(uint64(7), nil).Maybe() // 7 - 4 = 3 pending headers
423+
424+
// Initialize pendingHeaders properly
425+
pendingHeaders, err := NewPendingHeaders(store, logger)
426+
assert.NoError(t, err)
427+
m.pendingHeaders = pendingHeaders
428+
429+
heightBytes := make([]byte, 8)
430+
binary.LittleEndian.PutUint64(heightBytes, expectedDAIncludedHeight)
431+
store.On("SetMetadata", mock.Anything, DAIncludedHeightKey, heightBytes).Return(nil).Once()
432+
exec.On("SetFinal", mock.Anything, expectedDAIncludedHeight).Return(nil).Once()
433+
434+
// Expect RecordMetrics to be called with the correct parameters
435+
mockSequencer.On("RecordMetrics",
436+
float64(1.5), // gasPrice
437+
uint64(0), // blobSize
438+
coreda.StatusSuccess, // statusCode
439+
uint64(3), // numPendingBlocks (7 - 4 = 3)
440+
expectedDAIncludedHeight, // includedBlockHeight
441+
).Once()
442+
443+
err = m.incrementDAIncludedHeight(context.Background())
444+
assert.NoError(t, err)
445+
assert.Equal(t, expectedDAIncludedHeight, m.GetDAIncludedHeight())
446+
store.AssertExpectations(t)
447+
exec.AssertExpectations(t)
448+
mockSequencer.AssertExpectations(t)
449+
}
450+
372451
// Note: It is not practical to unit test a CompareAndSwap failure for incrementDAIncludedHeight
373452
// because the atomic value is always read at the start of the function, and there is no way to
374453
// inject a failure or race another goroutine reliably in a unit test. To test this path, the code

block/manager.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ var (
6969
// This allows for overriding the behavior in tests.
7070
type publishBlockFunc func(ctx context.Context) error
7171

72+
// MetricsRecorder defines the interface for sequencers that support recording metrics.
73+
// This interface is used to avoid duplication of the anonymous interface definition
74+
// across multiple files in the block package.
75+
type MetricsRecorder interface {
76+
RecordMetrics(gasPrice float64, blobSize uint64, statusCode coreda.StatusCode, numPendingBlocks uint64, includedBlockHeight uint64)
77+
}
78+
7279
func defaultSignaturePayloadProvider(header *types.Header) ([]byte, error) {
7380
return header.MarshalBinary()
7481
}

block/submitter.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ func (m *Manager) submitHeadersToDA(ctx context.Context) error {
106106
gasPrice = max(gasPrice, initialGasPrice)
107107
}
108108
m.logger.Debug("resetting DA layer submission options", "backoff", backoff, "gasPrice", gasPrice)
109+
110+
// Update sequencer metrics if the sequencer supports it
111+
if seq, ok := m.sequencer.(MetricsRecorder); ok {
112+
seq.RecordMetrics(gasPrice, res.BlobSize, res.Code, m.pendingHeaders.numPendingHeaders(), lastSubmittedHeight)
113+
}
109114
case coreda.StatusNotIncludedInBlock, coreda.StatusAlreadyInMempool:
110115
m.logger.Error("DA layer submission failed", "error", res.Message, "attempt", attempt)
111116
backoff = m.config.DA.BlockTime.Duration * time.Duration(m.config.DA.MempoolTTL) //nolint:gosec
@@ -256,6 +261,13 @@ func (m *Manager) submitDataToDA(ctx context.Context, signedData *types.SignedDa
256261

257262
m.DataCache().SetDAIncluded(signedData.DACommitment().String())
258263
m.sendNonBlockingSignalToDAIncluderCh()
264+
265+
// Update sequencer metrics if the sequencer supports it
266+
if seq, ok := m.sequencer.(MetricsRecorder); ok {
267+
daIncludedHeight := m.GetDAIncludedHeight()
268+
seq.RecordMetrics(gasPrice, res.BlobSize, res.Code, m.pendingHeaders.numPendingHeaders(), daIncludedHeight)
269+
}
270+
259271
return nil
260272

261273
case coreda.StatusNotIncludedInBlock, coreda.StatusAlreadyInMempool:

block/submitter_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,103 @@ func TestSubmitHeadersToDA_Failure(t *testing.T) {
209209
}
210210
}
211211

212+
// TestSubmitHeadersToDA_WithMetricsRecorder verifies that submitHeadersToDA calls RecordMetrics
213+
// when the sequencer implements the MetricsRecorder interface.
214+
func TestSubmitHeadersToDA_WithMetricsRecorder(t *testing.T) {
215+
da := &mocks.DA{}
216+
m := newTestManagerWithDA(t, da)
217+
218+
// Set up mock sequencer with metrics
219+
mockSequencer := new(MockSequencerWithMetrics)
220+
m.sequencer = mockSequencer
221+
222+
// Prepare a mock PendingHeaders with test data
223+
m.pendingHeaders = newPendingBlocks(t)
224+
225+
// Fill the pending headers with mock block data
226+
fillWithBlockData(context.Background(), t, m.pendingHeaders, "Test Submitting Headers")
227+
228+
// Simulate DA layer successfully accepting the header submission
229+
da.On("SubmitWithOptions", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
230+
Return([]coreda.ID{[]byte("id")}, nil)
231+
232+
// Expect RecordMetrics to be called with the correct parameters
233+
// Allow multiple calls since fillWithBlockData might create multiple headers
234+
mockSequencer.On("RecordMetrics",
235+
float64(1.0), // gasPrice (from newTestManagerWithDA)
236+
uint64(0), // blobSize (mocked as 0)
237+
coreda.StatusSuccess, // statusCode
238+
mock.AnythingOfType("uint64"), // numPendingBlocks (varies based on test data)
239+
mock.AnythingOfType("uint64"), // lastSubmittedHeight
240+
).Maybe()
241+
242+
// Call submitHeadersToDA and expect no error
243+
err := m.submitHeadersToDA(context.Background())
244+
assert.NoError(t, err)
245+
246+
// Verify that RecordMetrics was called at least once
247+
mockSequencer.AssertExpectations(t)
248+
}
249+
250+
// TestSubmitDataToDA_WithMetricsRecorder verifies that submitDataToDA calls RecordMetrics
251+
// when the sequencer implements the MetricsRecorder interface.
252+
func TestSubmitDataToDA_WithMetricsRecorder(t *testing.T) {
253+
da := &mocks.DA{}
254+
m := newTestManagerWithDA(t, da)
255+
256+
// Set up mock sequencer with metrics
257+
mockSequencer := new(MockSequencerWithMetrics)
258+
m.sequencer = mockSequencer
259+
260+
// Initialize pendingHeaders to avoid nil pointer dereference
261+
m.pendingHeaders = newPendingBlocks(t)
262+
263+
// Simulate DA success
264+
da.On("GasMultiplier", mock.Anything).Return(2.0, nil)
265+
da.On("SubmitWithOptions", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
266+
Return([]coreda.ID{[]byte("id")}, nil)
267+
268+
// Expect RecordMetrics to be called with the correct parameters
269+
mockSequencer.On("RecordMetrics",
270+
float64(1.0), // gasPrice (from newTestManagerWithDA)
271+
uint64(0), // blobSize (mocked as 0)
272+
coreda.StatusSuccess, // statusCode
273+
mock.AnythingOfType("uint64"), // numPendingBlocks (varies based on test data)
274+
mock.AnythingOfType("uint64"), // daIncludedHeight
275+
).Once()
276+
277+
pubKey, err := m.signer.GetPublic()
278+
require.NoError(t, err)
279+
addr, err := m.signer.GetAddress()
280+
require.NoError(t, err)
281+
282+
transactions := [][]byte{[]byte("tx1"), []byte("tx2")}
283+
284+
signedData := types.SignedData{
285+
Data: types.Data{
286+
Txs: make(types.Txs, len(transactions)),
287+
},
288+
Signer: types.Signer{
289+
Address: addr,
290+
PubKey: pubKey,
291+
},
292+
}
293+
294+
for i, tx := range transactions {
295+
signedData.Txs[i] = types.Tx(tx)
296+
}
297+
298+
signature, err := m.getDataSignature(&signedData.Data)
299+
require.NoError(t, err)
300+
signedData.Signature = signature
301+
302+
err = m.submitDataToDA(context.Background(), &signedData)
303+
assert.NoError(t, err)
304+
305+
// Verify that RecordMetrics was called
306+
mockSequencer.AssertExpectations(t)
307+
}
308+
212309
// TestCreateSignedDataFromBatch tests createSignedDataFromBatch for normal, empty, and error cases.
213310
func TestCreateSignedDataFromBatch(t *testing.T) {
214311
// Setup: create a Manager with a valid signer and genesis proposer address

sequencers/single/sequencer.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ func (c *Sequencer) GetNextBatch(ctx context.Context, req coresequencer.GetNextB
122122
}, nil
123123
}
124124

125-
func (c *Sequencer) recordMetrics(gasPrice float64, blobSize uint64, statusCode coreda.StatusCode, numPendingBlocks int, includedBlockHeight uint64) {
125+
// RecordMetrics updates the metrics with the given values.
126+
// This method is intended to be called by the block manager after submitting data to the DA layer.
127+
func (c *Sequencer) RecordMetrics(gasPrice float64, blobSize uint64, statusCode coreda.StatusCode, numPendingBlocks uint64, includedBlockHeight uint64) {
126128
if c.metrics != nil {
127129
c.metrics.GasPrice.Set(gasPrice)
128130
c.metrics.LastBlobSize.Set(float64(blobSize))

sequencers/single/sequencer_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,3 +431,86 @@ func TestSequencer_GetNextBatch_BeforeDASubmission(t *testing.T) {
431431
// Verify all mock expectations were met
432432
mockDA.AssertExpectations(t)
433433
}
434+
435+
// TestSequencer_RecordMetrics tests the RecordMetrics method to ensure it properly updates metrics.
436+
func TestSequencer_RecordMetrics(t *testing.T) {
437+
t.Run("With Metrics", func(t *testing.T) {
438+
// Create a sequencer with metrics enabled
439+
metrics, err := NopMetrics()
440+
require.NoError(t, err)
441+
442+
seq := &Sequencer{
443+
logger: log.NewNopLogger(),
444+
metrics: metrics,
445+
}
446+
447+
// Test values
448+
gasPrice := 1.5
449+
blobSize := uint64(1024)
450+
statusCode := coreda.StatusSuccess
451+
numPendingBlocks := uint64(5)
452+
includedBlockHeight := uint64(100)
453+
454+
// Call RecordMetrics - should not panic or error
455+
seq.RecordMetrics(gasPrice, blobSize, statusCode, numPendingBlocks, includedBlockHeight)
456+
457+
// Since we're using NopMetrics (discard metrics), we can't verify the actual values
458+
// but we can verify the method doesn't panic and completes successfully
459+
assert.NotNil(t, seq.metrics)
460+
})
461+
462+
t.Run("Without Metrics", func(t *testing.T) {
463+
// Create a sequencer without metrics
464+
seq := &Sequencer{
465+
logger: log.NewNopLogger(),
466+
metrics: nil, // No metrics
467+
}
468+
469+
// Test values
470+
gasPrice := 2.0
471+
blobSize := uint64(2048)
472+
statusCode := coreda.StatusNotIncludedInBlock
473+
numPendingBlocks := uint64(3)
474+
includedBlockHeight := uint64(200)
475+
476+
// Call RecordMetrics - should not panic even with nil metrics
477+
seq.RecordMetrics(gasPrice, blobSize, statusCode, numPendingBlocks, includedBlockHeight)
478+
479+
// Verify metrics is still nil
480+
assert.Nil(t, seq.metrics)
481+
})
482+
483+
t.Run("With Different Status Codes", func(t *testing.T) {
484+
// Create a sequencer with metrics
485+
metrics, err := NopMetrics()
486+
require.NoError(t, err)
487+
488+
seq := &Sequencer{
489+
logger: log.NewNopLogger(),
490+
metrics: metrics,
491+
}
492+
493+
// Test different status codes
494+
testCases := []struct {
495+
name string
496+
statusCode coreda.StatusCode
497+
}{
498+
{"Success", coreda.StatusSuccess},
499+
{"NotIncluded", coreda.StatusNotIncludedInBlock},
500+
{"AlreadyInMempool", coreda.StatusAlreadyInMempool},
501+
{"TooBig", coreda.StatusTooBig},
502+
{"ContextCanceled", coreda.StatusContextCanceled},
503+
}
504+
505+
for _, tc := range testCases {
506+
t.Run(tc.name, func(t *testing.T) {
507+
// Call RecordMetrics with different status codes
508+
seq.RecordMetrics(1.0, 512, tc.statusCode, 2, 50)
509+
510+
// Verify no panic occurred
511+
assert.NotNil(t, seq.metrics)
512+
})
513+
}
514+
})
515+
516+
}

0 commit comments

Comments
 (0)