diff --git a/.hack/devnet/kurtosis.devnet.config.yaml b/.hack/devnet/kurtosis.devnet.config.yaml index 3be9cc09..5c333777 100644 --- a/.hack/devnet/kurtosis.devnet.config.yaml +++ b/.hack/devnet/kurtosis.devnet.config.yaml @@ -12,4 +12,4 @@ network_params: shard_committee_period: 1 preset: mainnet additional_services: - - dora + - dora # needed for config exctraction diff --git a/.hack/devnet/run.sh b/.hack/devnet/run.sh index c239e582..ae791e3e 100755 --- a/.hack/devnet/run.sh +++ b/.hack/devnet/run.sh @@ -19,12 +19,12 @@ else --args-file "${config_file}" fi -# Get chain config -kurtosis files inspect "$ENCLAVE_NAME" el_cl_genesis_data ./config.yaml | tail -n +2 > "${__dir}/generated-chain-config.yaml" - # Get validator ranges kurtosis files inspect "$ENCLAVE_NAME" validator-ranges validator-ranges.yaml | tail -n +2 > "${__dir}/generated-validator-ranges.yaml" +# Get dora config +kurtosis files inspect "$ENCLAVE_NAME" dora-config dora-config.yaml | tail -n +2 > "${__dir}/generated-dora-kt-config.yaml" + ## Generate Dora config ENCLAVE_UUID=$(kurtosis enclave inspect "$ENCLAVE_NAME" --full-uuids | grep 'UUID:' | awk '{print $2}') @@ -107,6 +107,14 @@ database: file: "${__dir}/generated-database.sqlite" EOF +if [ -f ${__dir}/generated-dora-kt-config.yaml ]; then + fullcfg=$(yq eval-all 'select(fileIndex == 0) as $target | select(fileIndex == 1) as $source | $target.executionapi.endpoints = $source.executionapi.endpoints | $target' ${__dir}/generated-dora-config.yaml ${__dir}/generated-dora-kt-config.yaml) + if [ ! -z "$fullcfg" ]; then + echo "$fullcfg" > ${__dir}/generated-dora-config.yaml + rm ${__dir}/generated-dora-kt-config.yaml + fi +fi + cat < 0; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'NOT SUPPORTED'; +-- +goose StatementEnd \ No newline at end of file diff --git a/db/schema/sqlite/20250627025933_execution-times.sql b/db/schema/sqlite/20250627025933_execution-times.sql new file mode 100644 index 00000000..81959b25 --- /dev/null +++ b/db/schema/sqlite/20250627025933_execution-times.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE "slots" + ADD "min_exec_time" integer NOT NULL DEFAULT 0; + +ALTER TABLE "slots" + ADD "max_exec_time" integer NOT NULL DEFAULT 0; + +ALTER TABLE "slots" + ADD "exec_times" blob; + +ALTER TABLE "unfinalized_blocks" + ADD "min_exec_time" integer NOT NULL DEFAULT 0; + +ALTER TABLE "unfinalized_blocks" + ADD "max_exec_time" integer NOT NULL DEFAULT 0; + +ALTER TABLE "unfinalized_blocks" + ADD "exec_times" blob; + +-- Add index on execution time fields for performance queries +CREATE INDEX IF NOT EXISTS "idx_slots_exec_times" ON "slots" ("min_exec_time", "max_exec_time") WHERE "min_exec_time" > 0; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'NOT SUPPORTED'; +-- +goose StatementEnd \ No newline at end of file diff --git a/db/slots.go b/db/slots.go index 246c5a05..67e9f871 100644 --- a/db/slots.go +++ b/db/slots.go @@ -20,8 +20,8 @@ func InsertSlot(slot *dbtypes.Slot, tx *sqlx.Tx) error { attestation_count, deposit_count, exit_count, withdraw_count, withdraw_amount, attester_slashing_count, proposer_slashing_count, bls_change_count, eth_transaction_count, eth_block_number, eth_block_hash, eth_block_extra, eth_block_extra_text, sync_participation, fork_id, blob_count, eth_gas_used, - eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30) + eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay, min_exec_time, max_exec_time, exec_times + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33) ON CONFLICT (slot, root) DO UPDATE SET status = excluded.status, eth_block_extra = excluded.eth_block_extra, @@ -33,14 +33,14 @@ func InsertSlot(slot *dbtypes.Slot, tx *sqlx.Tx) error { attestation_count, deposit_count, exit_count, withdraw_count, withdraw_amount, attester_slashing_count, proposer_slashing_count, bls_change_count, eth_transaction_count, eth_block_number, eth_block_hash, eth_block_extra, eth_block_extra_text, sync_participation, fork_id, blob_count, eth_gas_used, - eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30)`, + eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay, min_exec_time, max_exec_time, exec_times + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33)`, }), slot.Slot, slot.Proposer, slot.Status, slot.Root, slot.ParentRoot, slot.StateRoot, slot.Graffiti, slot.GraffitiText, slot.AttestationCount, slot.DepositCount, slot.ExitCount, slot.WithdrawCount, slot.WithdrawAmount, slot.AttesterSlashingCount, slot.ProposerSlashingCount, slot.BLSChangeCount, slot.EthTransactionCount, slot.EthBlockNumber, slot.EthBlockHash, slot.EthBlockExtra, slot.EthBlockExtraText, slot.SyncParticipation, slot.ForkId, slot.BlobCount, slot.EthGasUsed, - slot.EthGasLimit, slot.EthBaseFee, slot.EthFeeRecipient, slot.BlockSize, slot.RecvDelay) + slot.EthGasLimit, slot.EthBaseFee, slot.EthFeeRecipient, slot.BlockSize, slot.RecvDelay, slot.MinExecTime, slot.MaxExecTime, slot.ExecTimes) if err != nil { return err } @@ -96,7 +96,7 @@ func GetSlotsRange(firstSlot uint64, lastSlot uint64, withMissing bool, withOrph "attestation_count", "deposit_count", "exit_count", "withdraw_count", "withdraw_amount", "attester_slashing_count", "proposer_slashing_count", "bls_change_count", "eth_transaction_count", "eth_block_number", "eth_block_hash", "eth_block_extra", "eth_block_extra_text", "sync_participation", "fork_id", "blob_count", "eth_gas_used", - "eth_gas_limit", "eth_base_fee", "eth_fee_recipient", "block_size", "recv_delay", + "eth_gas_limit", "eth_base_fee", "eth_fee_recipient", "block_size", "recv_delay", "min_exec_time", "max_exec_time", "exec_times", } for _, blockField := range blockFields { fmt.Fprintf(&sql, ", slots.%v AS \"block.%v\"", blockField, blockField) @@ -129,7 +129,7 @@ func GetSlotsByParentRoot(parentRoot []byte) []*dbtypes.Slot { attestation_count, deposit_count, exit_count, withdraw_count, withdraw_amount, attester_slashing_count, proposer_slashing_count, bls_change_count, eth_transaction_count, eth_block_number, eth_block_hash, eth_block_extra, eth_block_extra_text, sync_participation, fork_id, blob_count, eth_gas_used, - eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay + eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay, min_exec_time, max_exec_time, exec_times FROM slots WHERE parent_root = $1 ORDER BY slot DESC @@ -149,7 +149,7 @@ func GetSlotByRoot(root []byte) *dbtypes.Slot { attestation_count, deposit_count, exit_count, withdraw_count, withdraw_amount, attester_slashing_count, proposer_slashing_count, bls_change_count, eth_transaction_count, eth_block_number, eth_block_hash, eth_block_extra, eth_block_extra_text, sync_participation, fork_id, blob_count, eth_gas_used, - eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay + eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay, min_exec_time, max_exec_time, exec_times FROM slots WHERE root = $1 `, root) @@ -176,7 +176,7 @@ func GetSlotsByRoots(roots [][]byte) map[phase0.Root]*dbtypes.Slot { attestation_count, deposit_count, exit_count, withdraw_count, withdraw_amount, attester_slashing_count, proposer_slashing_count, bls_change_count, eth_transaction_count, eth_block_number, eth_block_hash, eth_block_extra, eth_block_extra_text, sync_participation, fork_id, blob_count, eth_gas_used, - eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay + eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay, min_exec_time, max_exec_time, exec_times FROM slots WHERE root IN (%v) ORDER BY slot DESC`, @@ -236,7 +236,7 @@ func GetSlotsByBlockHash(blockHash []byte) []*dbtypes.Slot { attestation_count, deposit_count, exit_count, withdraw_count, withdraw_amount, attester_slashing_count, proposer_slashing_count, bls_change_count, eth_transaction_count, eth_block_number, eth_block_hash, eth_block_extra, eth_block_extra_text, sync_participation, fork_id, blob_count, eth_gas_used, - eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay + eth_gas_limit, eth_base_fee, eth_fee_recipient, block_size, recv_delay, min_exec_time, max_exec_time, exec_times FROM slots WHERE eth_block_hash = $1 ORDER BY slot DESC @@ -297,7 +297,7 @@ func GetFilteredSlots(filter *dbtypes.BlockFilter, firstSlot uint64, offset uint "attestation_count", "deposit_count", "exit_count", "withdraw_count", "withdraw_amount", "attester_slashing_count", "proposer_slashing_count", "bls_change_count", "eth_transaction_count", "eth_block_number", "eth_block_hash", "eth_block_extra", "eth_block_extra_text", "sync_participation", "fork_id", "blob_count", "eth_gas_used", - "eth_gas_limit", "eth_base_fee", "eth_fee_recipient", "block_size", "recv_delay", + "eth_gas_limit", "eth_base_fee", "eth_fee_recipient", "block_size", "recv_delay", "min_exec_time", "max_exec_time", "exec_times", } for _, blockField := range blockFields { fmt.Fprintf(&sql, ", slots.%v AS \"block.%v\"", blockField, blockField) diff --git a/db/unfinalized_blocks.go b/db/unfinalized_blocks.go index 57caaba0..34e41767 100644 --- a/db/unfinalized_blocks.go +++ b/db/unfinalized_blocks.go @@ -12,15 +12,15 @@ func InsertUnfinalizedBlock(block *dbtypes.UnfinalizedBlock, tx *sqlx.Tx) error _, err := tx.Exec(EngineQuery(map[dbtypes.DBEngineType]string{ dbtypes.DBEnginePgsql: ` INSERT INTO unfinalized_blocks ( - root, slot, header_ver, header_ssz, block_ver, block_ssz, status, fork_id, recv_delay - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + root, slot, header_ver, header_ssz, block_ver, block_ssz, status, fork_id, recv_delay, min_exec_time, max_exec_time, exec_times + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (root) DO NOTHING`, dbtypes.DBEngineSqlite: ` INSERT OR IGNORE INTO unfinalized_blocks ( - root, slot, header_ver, header_ssz, block_ver, block_ssz, status, fork_id, recv_delay - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + root, slot, header_ver, header_ssz, block_ver, block_ssz, status, fork_id, recv_delay, min_exec_time, max_exec_time, exec_times + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, }), - block.Root, block.Slot, block.HeaderVer, block.HeaderSSZ, block.BlockVer, block.BlockSSZ, block.Status, block.ForkId, block.RecvDelay) + block.Root, block.Slot, block.HeaderVer, block.HeaderSSZ, block.BlockVer, block.BlockSSZ, block.Status, block.ForkId, block.RecvDelay, block.MinExecTime, block.MaxExecTime, block.ExecTimes) if err != nil { return err } @@ -77,13 +77,21 @@ func UpdateUnfinalizedBlockForkId(roots [][]byte, forkId uint64, tx *sqlx.Tx) er return nil } +func UpdateUnfinalizedBlockExecutionTimes(root []byte, minExecTime uint32, maxExecTime uint32, execTimes []byte, tx *sqlx.Tx) error { + _, err := tx.Exec(`UPDATE unfinalized_blocks SET min_exec_time = $1, max_exec_time = $2, exec_times = $3 WHERE root = $4`, minExecTime, maxExecTime, execTimes, root) + if err != nil { + return err + } + return nil +} + func GetUnfinalizedBlocks(filter *dbtypes.UnfinalizedBlockFilter) []*dbtypes.UnfinalizedBlock { blockRefs := []*dbtypes.UnfinalizedBlock{} var sql strings.Builder args := []any{} - fmt.Fprint(&sql, `SELECT root, slot, status, fork_id, header_ver, header_ssz, recv_delay`) + fmt.Fprint(&sql, `SELECT root, slot, status, fork_id, header_ver, header_ssz, recv_delay, min_exec_time, max_exec_time, exec_times`) if filter == nil || filter.WithBody { fmt.Fprint(&sql, `, block_ver, block_ssz`) @@ -120,7 +128,7 @@ func StreamUnfinalizedBlocks(slot uint64, cb func(block *dbtypes.UnfinalizedBloc var sql strings.Builder args := []any{slot} - fmt.Fprint(&sql, `SELECT root, slot, header_ver, header_ssz, block_ver, block_ssz, status, fork_id, recv_delay FROM unfinalized_blocks WHERE slot >= $1`) + fmt.Fprint(&sql, `SELECT root, slot, header_ver, header_ssz, block_ver, block_ssz, status, fork_id, recv_delay, min_exec_time, max_exec_time, exec_times FROM unfinalized_blocks WHERE slot >= $1`) rows, err := ReaderDb.Query(sql.String(), args...) if err != nil { @@ -130,7 +138,10 @@ func StreamUnfinalizedBlocks(slot uint64, cb func(block *dbtypes.UnfinalizedBloc for rows.Next() { block := dbtypes.UnfinalizedBlock{} - err := rows.Scan(&block.Root, &block.Slot, &block.HeaderVer, &block.HeaderSSZ, &block.BlockVer, &block.BlockSSZ, &block.Status, &block.ForkId, &block.RecvDelay) + err := rows.Scan( + &block.Root, &block.Slot, &block.HeaderVer, &block.HeaderSSZ, &block.BlockVer, &block.BlockSSZ, &block.Status, &block.ForkId, &block.RecvDelay, + &block.MinExecTime, &block.MaxExecTime, &block.ExecTimes, + ) if err != nil { logger.Errorf("Error while scanning unfinalized block: %v", err) return err @@ -144,7 +155,7 @@ func StreamUnfinalizedBlocks(slot uint64, cb func(block *dbtypes.UnfinalizedBloc func GetUnfinalizedBlock(root []byte) *dbtypes.UnfinalizedBlock { block := dbtypes.UnfinalizedBlock{} err := ReaderDb.Get(&block, ` - SELECT root, slot, header_ver, header_ssz, block_ver, block_ssz, status, fork_id, recv_delay + SELECT root, slot, header_ver, header_ssz, block_ver, block_ssz, status, fork_id, recv_delay, min_exec_time, max_exec_time, exec_times FROM unfinalized_blocks WHERE root = $1 `, root) diff --git a/dbtypes/dbtypes.go b/dbtypes/dbtypes.go index 84ba6f93..1c0e6117 100644 --- a/dbtypes/dbtypes.go +++ b/dbtypes/dbtypes.go @@ -55,6 +55,9 @@ type Slot struct { ForkId uint64 `db:"fork_id"` BlockSize uint64 `db:"block_size"` RecvDelay int32 `db:"recv_delay"` + MinExecTime uint32 `db:"min_exec_time"` + MaxExecTime uint32 `db:"max_exec_time"` + ExecTimes []byte `db:"exec_times"` } type Epoch struct { @@ -110,15 +113,18 @@ const ( ) type UnfinalizedBlock struct { - Root []byte `db:"root"` - Slot uint64 `db:"slot"` - HeaderVer uint64 `db:"header_ver"` - HeaderSSZ []byte `db:"header_ssz"` - BlockVer uint64 `db:"block_ver"` - BlockSSZ []byte `db:"block_ssz"` - Status UnfinalizedBlockStatus `db:"status"` - ForkId uint64 `db:"fork_id"` - RecvDelay int32 `db:"recv_delay"` + Root []byte `db:"root"` + Slot uint64 `db:"slot"` + HeaderVer uint64 `db:"header_ver"` + HeaderSSZ []byte `db:"header_ssz"` + BlockVer uint64 `db:"block_ver"` + BlockSSZ []byte `db:"block_ssz"` + Status UnfinalizedBlockStatus `db:"status"` + ForkId uint64 `db:"fork_id"` + RecvDelay int32 `db:"recv_delay"` + MinExecTime uint32 `db:"min_exec_time"` + MaxExecTime uint32 `db:"max_exec_time"` + ExecTimes []byte `db:"exec_times"` } type UnfinalizedEpoch struct { diff --git a/handlers/blocks.go b/handlers/blocks.go index 2ac8f7c2..b67232c9 100644 --- a/handlers/blocks.go +++ b/handlers/blocks.go @@ -11,8 +11,10 @@ import ( "time" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethpandaops/dora/clients/execution" "github.com/ethpandaops/dora/db" "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/indexer/beacon" "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/templates" "github.com/ethpandaops/dora/types/models" @@ -113,6 +115,7 @@ func buildBlocksPageData(firstSlot uint64, pageSize uint64, displayColumns strin 16: true, 17: true, 18: false, + 19: false, } } else { for col := range displayMap { @@ -138,6 +141,7 @@ func buildBlocksPageData(firstSlot uint64, pageSize uint64, displayColumns strin pageData.DisplayMevBlock = displayMap[16] pageData.DisplayBlockSize = displayMap[17] pageData.DisplayRecvDelay = displayMap[18] + pageData.DisplayExecTime = displayMap[19] pageData.DisplayColCount = uint64(len(displayMap)) // Build column selection URL parameter if not default @@ -291,6 +295,44 @@ func buildBlocksPageData(firstSlot uint64, pageSize uint64, displayColumns strin } } + // Add execution times if available + if pageData.DisplayExecTime && dbSlot.MinExecTime > 0 && dbSlot.MaxExecTime > 0 { + slotData.MinExecTime = dbSlot.MinExecTime + slotData.MaxExecTime = dbSlot.MaxExecTime + + // Deserialize execution times if available + if len(dbSlot.ExecTimes) > 0 { + execTimes := []beacon.ExecutionTime{} + if err := services.GlobalBeaconService.GetBeaconIndexer().GetDynSSZ().UnmarshalSSZ(&execTimes, dbSlot.ExecTimes); err == nil { + slotData.ExecutionTimes = make([]models.ExecutionTimeDetail, 0, len(execTimes)) + totalAvg := uint64(0) + totalCount := uint64(0) + + for _, et := range execTimes { + detail := models.ExecutionTimeDetail{ + ClientType: getClientTypeName(et.ClientType), + MinTime: et.MinTime, + MaxTime: et.MaxTime, + AvgTime: et.AvgTime, + Count: et.Count, + } + slotData.ExecutionTimes = append(slotData.ExecutionTimes, detail) + totalAvg += uint64(et.AvgTime) * uint64(et.Count) + totalCount += uint64(et.Count) + } + + if totalCount > 0 { + slotData.AvgExecTime = uint32(totalAvg / totalCount) + } + } + } + + // If we don't have detailed times, calculate average from min/max + if slotData.AvgExecTime == 0 { + slotData.AvgExecTime = (slotData.MinExecTime + slotData.MaxExecTime) / 2 + } + } + pageData.Blocks = append(pageData.Blocks, slotData) blockCount++ buildBlocksPageSlotGraph(pageData, slotData, &maxOpenFork, openForks, isFirstPage) @@ -315,6 +357,14 @@ func buildBlocksPageData(firstSlot uint64, pageSize uint64, displayColumns strin return pageData, cacheTimeout } +func getClientTypeName(clientType uint8) string { + if clientType > 0 { + return execution.ClientType(clientType).String() + } + + return fmt.Sprintf("Unknown(%d)", clientType) +} + func buildBlocksPageSlotGraph(pageData *models.BlocksPageData, slotData *models.BlocksPageDataSlot, maxOpenFork *int, openForks map[int][]byte, isFirstPage bool) { // fork tree var forkGraphIdx int = -1 diff --git a/handlers/slots.go b/handlers/slots.go index 1302435b..189cdb7e 100644 --- a/handlers/slots.go +++ b/handlers/slots.go @@ -13,6 +13,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethpandaops/dora/db" "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/indexer/beacon" "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/templates" "github.com/ethpandaops/dora/types/models" @@ -94,6 +95,12 @@ func buildSlotsPageData(firstSlot uint64, pageSize uint64, displayColumns string } } if len(displayMap) == 0 { + // Check if snooper clients are configured + hasSnooperClients := false + if snooperManager := services.GlobalBeaconService.GetSnooperManager(); snooperManager != nil { + hasSnooperClients = snooperManager.HasClients() + } + displayMap = map[uint64]bool{ 1: true, 2: true, @@ -112,7 +119,8 @@ func buildSlotsPageData(firstSlot uint64, pageSize uint64, displayColumns string 15: false, 16: false, 17: false, - 18: true, + 18: !hasSnooperClients, // Disable receive delay if snooper clients exist + 19: hasSnooperClients, // Enable exec time if snooper clients exist } } else { for col := range displayMap { @@ -138,6 +146,7 @@ func buildSlotsPageData(firstSlot uint64, pageSize uint64, displayColumns string pageData.DisplayMevBlock = displayMap[16] pageData.DisplayBlockSize = displayMap[17] pageData.DisplayRecvDelay = displayMap[18] + pageData.DisplayExecTime = displayMap[19] pageData.DisplayColCount = uint64(len(displayMap)) // Build column selection URL parameter if not default @@ -291,6 +300,44 @@ func buildSlotsPageData(firstSlot uint64, pageSize uint64, displayColumns string } } + // Add execution times if available + if pageData.DisplayExecTime && dbSlot.MinExecTime > 0 && dbSlot.MaxExecTime > 0 { + slotData.MinExecTime = dbSlot.MinExecTime + slotData.MaxExecTime = dbSlot.MaxExecTime + + // Deserialize execution times if available + if len(dbSlot.ExecTimes) > 0 { + execTimes := []beacon.ExecutionTime{} + if err := services.GlobalBeaconService.GetBeaconIndexer().GetDynSSZ().UnmarshalSSZ(&execTimes, dbSlot.ExecTimes); err == nil { + slotData.ExecutionTimes = make([]models.ExecutionTimeDetail, 0, len(execTimes)) + totalAvg := uint64(0) + totalCount := uint64(0) + + for _, et := range execTimes { + detail := models.ExecutionTimeDetail{ + ClientType: getClientTypeName(et.ClientType), + MinTime: et.MinTime, + MaxTime: et.MaxTime, + AvgTime: et.AvgTime, + Count: et.Count, + } + slotData.ExecutionTimes = append(slotData.ExecutionTimes, detail) + totalAvg += uint64(et.AvgTime) * uint64(et.Count) + totalCount += uint64(et.Count) + } + + if totalCount > 0 { + slotData.AvgExecTime = uint32(totalAvg / totalCount) + } + } + } + + // If we don't have detailed times, calculate average from min/max + if slotData.AvgExecTime == 0 { + slotData.AvgExecTime = (slotData.MinExecTime + slotData.MaxExecTime) / 2 + } + } + pageData.Slots = append(pageData.Slots, slotData) blockCount++ buildSlotsPageSlotGraph(pageData, slotData, &maxOpenFork, openForks, isFirstPage) diff --git a/handlers/slots_filtered.go b/handlers/slots_filtered.go index 83a0e6b7..16b14112 100644 --- a/handlers/slots_filtered.go +++ b/handlers/slots_filtered.go @@ -11,6 +11,7 @@ import ( "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethpandaops/dora/db" "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/indexer/beacon" "github.com/ethpandaops/dora/services" "github.com/ethpandaops/dora/templates" "github.com/ethpandaops/dora/types/models" @@ -157,6 +158,12 @@ func buildFilteredSlotsPageData(pageIdx uint64, pageSize uint64, graffiti string } } if len(displayMap) == 0 { + // Check if snooper clients are configured + hasSnooperClients := false + if snooperManager := services.GlobalBeaconService.GetSnooperManager(); snooperManager != nil { + hasSnooperClients = snooperManager.HasClients() + } + displayMap = map[uint64]bool{ 1: true, 2: true, @@ -174,7 +181,8 @@ func buildFilteredSlotsPageData(pageIdx uint64, pageSize uint64, graffiti string 14: false, 15: false, 16: false, - 17: true, + 17: !hasSnooperClients, // Disable receive delay if snooper clients exist + 18: hasSnooperClients, // Enable exec time if snooper clients exist } } else { displayList := make([]uint64, len(displayMap)) @@ -221,6 +229,7 @@ func buildFilteredSlotsPageData(pageIdx uint64, pageSize uint64, graffiti string DisplayMevBlock: displayMap[15], DisplayBlockSize: displayMap[16], DisplayRecvDelay: displayMap[17], + DisplayExecTime: displayMap[18], DisplayColCount: uint64(len(displayMap)), } logrus.Debugf("slots_filtered page called: %v:%v [%v/%v]", pageIdx, pageSize, graffiti, extradata) @@ -341,6 +350,44 @@ func buildFilteredSlotsPageData(pageIdx uint64, pageSize uint64, graffiti string slotData.MevBlockRelays = strings.Join(relays, ", ") } } + + // Add execution times if available + if pageData.DisplayExecTime && dbBlock.Block.MinExecTime > 0 && dbBlock.Block.MaxExecTime > 0 { + slotData.MinExecTime = dbBlock.Block.MinExecTime + slotData.MaxExecTime = dbBlock.Block.MaxExecTime + + // Deserialize execution times if available + if len(dbBlock.Block.ExecTimes) > 0 { + execTimes := []beacon.ExecutionTime{} + if err := services.GlobalBeaconService.GetBeaconIndexer().GetDynSSZ().UnmarshalSSZ(&execTimes, dbBlock.Block.ExecTimes); err == nil { + slotData.ExecutionTimes = make([]models.ExecutionTimeDetail, 0, len(execTimes)) + totalAvg := uint64(0) + totalCount := uint64(0) + + for _, et := range execTimes { + detail := models.ExecutionTimeDetail{ + ClientType: getClientTypeName(et.ClientType), + MinTime: et.MinTime, + MaxTime: et.MaxTime, + AvgTime: et.AvgTime, + Count: et.Count, + } + slotData.ExecutionTimes = append(slotData.ExecutionTimes, detail) + totalAvg += uint64(et.AvgTime) * uint64(et.Count) + totalCount += uint64(et.Count) + } + + if totalCount > 0 { + slotData.AvgExecTime = uint32(totalAvg / totalCount) + } + } + } + + // If we don't have detailed times, calculate average from min/max + if slotData.AvgExecTime == 0 { + slotData.AvgExecTime = (slotData.MinExecTime + slotData.MaxExecTime) / 2 + } + } } pageData.Slots = append(pageData.Slots, slotData) } diff --git a/indexer/beacon/block.go b/indexer/beacon/block.go index da1d9892..5d3d50f5 100644 --- a/indexer/beacon/block.go +++ b/indexer/beacon/block.go @@ -13,6 +13,7 @@ import ( btypes "github.com/ethpandaops/dora/blockdb/types" "github.com/ethpandaops/dora/db" "github.com/ethpandaops/dora/dbtypes" + "github.com/jmoiron/sqlx" dynssz "github.com/pk910/dynamic-ssz" ) @@ -33,6 +34,11 @@ type Block struct { block *spec.VersionedSignedBeaconBlock blockIndex *BlockBodyIndex recvDelay int32 + executionTimes []ExecutionTime // execution times from snooper clients + minExecutionTime uint16 + maxExecutionTime uint16 + execTimeUpdate *time.Ticker + executionTimesMux sync.RWMutex isInFinalizedDb bool // block is in finalized table (slots) isInUnfinalizedDb bool // block is in unfinalized table (unfinalized_blocks) isDisposed bool // block is disposed @@ -331,16 +337,24 @@ func (block *Block) buildUnfinalizedBlock(compress bool) (*dbtypes.UnfinalizedBl return nil, fmt.Errorf("marshal block ssz failed: %v", err) } + execTimesSSZ, err := block.dynSsz.MarshalSSZ(block.executionTimes) + if err != nil { + return nil, fmt.Errorf("marshal exec times ssz failed: %v", err) + } + return &dbtypes.UnfinalizedBlock{ - Root: block.Root[:], - Slot: uint64(block.Slot), - HeaderVer: 1, - HeaderSSZ: headerSSZ, - BlockVer: blockVer, - BlockSSZ: blockSSZ, - Status: 0, - ForkId: uint64(block.forkId), - RecvDelay: block.recvDelay, + Root: block.Root[:], + Slot: uint64(block.Slot), + HeaderVer: 1, + HeaderSSZ: headerSSZ, + BlockVer: blockVer, + BlockSSZ: blockSSZ, + Status: 0, + ForkId: uint64(block.forkId), + RecvDelay: block.recvDelay, + MinExecTime: uint32(block.minExecutionTime), + MaxExecTime: uint32(block.maxExecutionTime), + ExecTimes: execTimesSSZ, }, nil } @@ -487,3 +501,95 @@ func (block *Block) GetDbConsolidationRequests(indexer *Indexer, isCanonical boo func (block *Block) GetForkId() ForkKey { return block.forkId } + +// AddExecutionTime adds an execution time to this block +func (block *Block) AddExecutionTime(execTime ExecutionTime) { + block.executionTimesMux.Lock() + defer block.executionTimesMux.Unlock() + + // Check if we already have an entry for this client type + for i := range block.executionTimes { + existingExecTime := &block.executionTimes[i] + if existingExecTime.ClientType == execTime.ClientType { + // Update existing entry with min/max and increment count + if execTime.MinTime < existingExecTime.MinTime { + existingExecTime.MinTime = execTime.MinTime + if block.minExecutionTime == 0 || execTime.MinTime < block.minExecutionTime { + block.minExecutionTime = execTime.MinTime + } + } + if execTime.MaxTime > existingExecTime.MaxTime { + existingExecTime.MaxTime = execTime.MaxTime + if block.maxExecutionTime == 0 || execTime.MaxTime > block.maxExecutionTime { + block.maxExecutionTime = execTime.MaxTime + } + } + existingExecTime.AvgTime = (existingExecTime.AvgTime*existingExecTime.Count + execTime.AvgTime) / (existingExecTime.Count + 1) + existingExecTime.Count += execTime.Count + return + } + } + + // Add new entry + block.executionTimes = append(block.executionTimes, execTime) + + if block.minExecutionTime == 0 || execTime.MinTime < block.minExecutionTime { + block.minExecutionTime = execTime.MinTime + } + if block.maxExecutionTime == 0 || execTime.MaxTime > block.maxExecutionTime { + block.maxExecutionTime = execTime.MaxTime + } + + if block.execTimeUpdate == nil { + block.execTimeUpdate = time.NewTicker(10 * time.Second) + go func() { + <-block.execTimeUpdate.C + block.execTimeUpdate.Stop() + block.execTimeUpdate = nil + if block.isDisposed || !block.isInUnfinalizedDb { + return + } + + block.executionTimesMux.RLock() + defer block.executionTimesMux.RUnlock() + + execTimesSSZ, err := block.dynSsz.MarshalSSZ(block.executionTimes) + if err != nil { + return + } + + db.RunDBTransaction(func(tx *sqlx.Tx) error { + return db.UpdateUnfinalizedBlockExecutionTimes(block.Root[:], uint32(block.minExecutionTime), uint32(block.maxExecutionTime), execTimesSSZ, tx) + }) + }() + } +} + +func (block *Block) restoreExecutionTimes(minExecutionTime uint16, maxExecutionTime uint16, execTimesSSZ []byte) error { + if block.isDisposed || !block.isInUnfinalizedDb { + return nil + } + + block.executionTimesMux.Lock() + defer block.executionTimesMux.Unlock() + + block.minExecutionTime = minExecutionTime + block.maxExecutionTime = maxExecutionTime + + block.executionTimes = []ExecutionTime{} + return block.dynSsz.UnmarshalSSZ(&block.executionTimes, execTimesSSZ) +} + +// GetExecutionTimes returns a copy of the execution times for this block +func (block *Block) GetExecutionTimes() []ExecutionTime { + block.executionTimesMux.RLock() + defer block.executionTimesMux.RUnlock() + + if len(block.executionTimes) == 0 { + return nil + } + + result := make([]ExecutionTime, len(block.executionTimes)) + copy(result, block.executionTimes) + return result +} diff --git a/indexer/beacon/client.go b/indexer/beacon/client.go index dc01d6f5..ecc3d23d 100644 --- a/indexer/beacon/client.go +++ b/indexer/beacon/client.go @@ -11,9 +11,11 @@ import ( v1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethereum/go-ethereum/common" "github.com/ethpandaops/dora/clients/consensus" "github.com/ethpandaops/dora/db" "github.com/ethpandaops/dora/dbtypes" + "github.com/ethpandaops/dora/utils" "github.com/jmoiron/sqlx" "github.com/sirupsen/logrus" ) @@ -30,8 +32,8 @@ type Client struct { archive bool skipValidators bool - blockSubscription *consensus.Subscription[*v1.BlockEvent] - headSubscription *consensus.Subscription[*v1.HeadEvent] + blockSubscription *utils.Subscription[*v1.BlockEvent] + headSubscription *utils.Subscription[*v1.HeadEvent] headRoot phase0.Root } @@ -401,6 +403,33 @@ func (c *Client) processBlock(slot phase0.Slot, root phase0.Root, header *phase0 if slot >= finalizedSlot && isNew { c.indexer.blockCache.addBlockToParentMap(block) c.indexer.blockCache.addBlockToExecBlockMap(block) + + // Check for cached execution times and add them to the block + blockIndex := block.GetBlockIndex() + if blockIndex != nil && !bytes.Equal(blockIndex.ExecutionHash[:], zeroHash[:]) { + executionHash := common.Hash(blockIndex.ExecutionHash) + cachedTimes := c.indexer.executionTimeProvider.GetAndDeleteExecutionTimes(executionHash) + for _, cachedTime := range cachedTimes { + // Convert the cached time to beacon ExecutionTime format + client := cachedTime.GetClient() + execTime := ExecutionTime{ + ClientType: client.GetClientType().Uint8(), + MinTime: cachedTime.GetTime(), + MaxTime: cachedTime.GetTime(), + AvgTime: cachedTime.GetTime(), + Count: 1, + } + block.AddExecutionTime(execTime) + } + if len(cachedTimes) > 0 { + c.logger.WithFields(map[string]interface{}{ + "slot": block.Slot, + "execution_hash": executionHash.Hex(), + "times_count": len(cachedTimes), + }).Debug("Added cached execution times to block") + } + } + t1 := time.Now() // fork detection diff --git a/indexer/beacon/execution_times.go b/indexer/beacon/execution_times.go new file mode 100644 index 00000000..26e06815 --- /dev/null +++ b/indexer/beacon/execution_times.go @@ -0,0 +1,52 @@ +package beacon + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethpandaops/dora/clients/execution" +) + +// ExecutionTime represents execution timing data for a specific client +type ExecutionTime struct { + ClientType uint8 // client type + MinTime uint16 // milliseconds + MaxTime uint16 // milliseconds + AvgTime uint16 // milliseconds + Count uint16 // number of clients +} + +// ExecutionTimeData is an interface for execution time data +type ExecutionTimeData interface { + GetClient() *execution.Client + GetTime() uint16 +} + +// ExecutionTimeProvider is an interface for getting execution times from cache +type ExecutionTimeProvider interface { + GetAndDeleteExecutionTimes(blockHash common.Hash) []ExecutionTimeData +} + +// NoOpExecutionTimeProvider is a no-op implementation +type NoOpExecutionTimeProvider struct{} + +func (n *NoOpExecutionTimeProvider) GetAndDeleteExecutionTimes(blockHash common.Hash) []ExecutionTimeData { + return nil +} + +// CalculateMinMaxTimesForStorage calculates the overall min/max times from a list of client execution times +func CalculateMinMaxTimesForStorage(times []ExecutionTime) (uint32, uint32) { + if len(times) == 0 { + return 0, 0 + } + + var minTime, maxTime uint32 + for _, time := range times { + if minTime == 0 || uint32(time.MinTime) < minTime { + minTime = uint32(time.MinTime) + } + if uint32(time.MaxTime) > maxTime { + maxTime = uint32(time.MaxTime) + } + } + + return minTime, maxTime +} diff --git a/indexer/beacon/indexer.go b/indexer/beacon/indexer.go index 3377acb1..eca9d0b4 100644 --- a/indexer/beacon/indexer.go +++ b/indexer/beacon/indexer.go @@ -25,10 +25,11 @@ const EtherGweiFactor = 1_000_000_000 // Indexer is responsible for indexing the ethereum beacon chain. type Indexer struct { - logger logrus.FieldLogger - consensusPool *consensus.Pool - dynSsz *dynssz.DynSsz - synchronizer *synchronizer + logger logrus.FieldLogger + consensusPool *consensus.Pool + dynSsz *dynssz.DynSsz + synchronizer *synchronizer + executionTimeProvider ExecutionTimeProvider // configuration disableSync bool @@ -58,8 +59,8 @@ type Indexer struct { lastPrunedEpoch phase0.Epoch lastPruneRunEpoch phase0.Epoch lastPrecalcRunEpoch phase0.Epoch - finalitySubscription *consensus.Subscription[*v1.Finality] - wallclockSubscription *consensus.Subscription[*ethwallclock.Slot] + finalitySubscription *utils.Subscription[*v1.Finality] + wallclockSubscription *utils.Subscription[*ethwallclock.Slot] // canonical head state canonicalHeadMutex sync.Mutex @@ -122,6 +123,10 @@ func NewIndexer(logger logrus.FieldLogger, consensusPool *consensus.Pool) *Index return indexer } +func (indexer *Indexer) SetExecutionTimeProvider(executionTimeProvider ExecutionTimeProvider) { + indexer.executionTimeProvider = executionTimeProvider +} + func (indexer *Indexer) GetActivityHistoryLength() uint16 { return indexer.activityHistoryLength } @@ -334,13 +339,18 @@ func (indexer *Indexer) StartIndexer() { block.isInUnfinalizedDb = true block.recvDelay = dbBlock.RecvDelay + err := block.restoreExecutionTimes(uint16(dbBlock.MinExecTime), uint16(dbBlock.MaxExecTime), dbBlock.ExecTimes) + if err != nil { + indexer.logger.Warnf("failed restoring execution times for block %v [%x] from db: %v", dbBlock.Slot, dbBlock.Root, err) + } + if dbBlock.HeaderVer != 1 { indexer.logger.Warnf("failed unmarshal unfinalized block header %v [%x] from db: unsupported header version", dbBlock.Slot, dbBlock.Root) return } header := &phase0.SignedBeaconBlockHeader{} - err := header.UnmarshalSSZ(dbBlock.HeaderSSZ) + err = header.UnmarshalSSZ(dbBlock.HeaderSSZ) if err != nil { indexer.logger.Warnf("failed unmarshal unfinalized block header %v [%x] from db: %v", dbBlock.Slot, dbBlock.Root, err) return diff --git a/indexer/beacon/writedb.go b/indexer/beacon/writedb.go index 5ac567e2..e742aa85 100644 --- a/indexer/beacon/writedb.go +++ b/indexer/beacon/writedb.go @@ -314,6 +314,23 @@ func (dbw *dbWriter) buildDbBlock(block *Block, epochStats *EpochStats, override dbBlock.EthBlockExtraText = utils.GraffitiToString(executionExtraData[:]) dbBlock.WithdrawCount = uint64(len(executionWithdrawals)) + // Get execution times from the block + if execTimes := block.GetExecutionTimes(); len(execTimes) > 0 { + // Calculate min/max times for quick queries + minTime, maxTime := CalculateMinMaxTimesForStorage(execTimes) + if minTime > 0 { + dbBlock.MinExecTime = minTime + dbBlock.MaxExecTime = maxTime + + execTimesSSZ, err := block.dynSsz.MarshalSSZ(execTimes) + if err != nil { + dbw.indexer.logger.Warnf("error while building db blocks: failed to marshal execution times: %v", err) + } else { + dbBlock.ExecTimes = execTimesSSZ + } + } + } + withdrawalAmountOverflow := false for _, withdrawal := range executionWithdrawals { dbBlock.WithdrawAmount += uint64(withdrawal.Amount) diff --git a/indexer/snooper/execution_time_cache.go b/indexer/snooper/execution_time_cache.go new file mode 100644 index 00000000..189deb81 --- /dev/null +++ b/indexer/snooper/execution_time_cache.go @@ -0,0 +1,193 @@ +package snooper + +import ( + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethpandaops/dora/clients/execution" +) + +// ExecutionTimeCache temporarily stores execution times for blocks that haven't been processed yet +type ExecutionTimeCache struct { + data map[common.Hash][]BeaconExecutionTime + mu sync.RWMutex + ttl time.Duration + cleanup *time.Ticker + stopCh chan struct{} +} + +// NewExecutionTimeCache creates a new execution time cache +func NewExecutionTimeCache(ttl time.Duration) *ExecutionTimeCache { + cache := &ExecutionTimeCache{ + data: make(map[common.Hash][]BeaconExecutionTime), + ttl: ttl, + stopCh: make(chan struct{}), + } + + // Start cleanup routine + cache.cleanup = time.NewTicker(ttl / 2) // Clean up twice per TTL period + go cache.cleanupRoutine() + + return cache +} + +// Set stores an execution time for a specific block hash and client type +func (c *ExecutionTimeCache) Set(blockHash common.Hash, client *execution.Client, execTime time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + ms := uint16(execTime.Milliseconds()) + if ms > 60000 { + ms = 60000 // Cap at 60 seconds + } + + entry, exists := c.data[blockHash] + if !exists { + entry = make([]BeaconExecutionTime, 0) + } + + entry = append(entry, BeaconExecutionTime{ + Client: client, + Time: ms, + Added: time.Now(), + }) + + c.data[blockHash] = entry +} + +// Get retrieves execution times for a specific block hash +func (c *ExecutionTimeCache) Get(blockHash common.Hash) []BeaconExecutionTime { + c.mu.RLock() + defer c.mu.RUnlock() + + entry, exists := c.data[blockHash] + if !exists { + return nil + } + + return entry +} + +// Delete removes execution times for a specific block hash +func (c *ExecutionTimeCache) Delete(blockHash common.Hash) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.data, blockHash) +} + +// GetAndDelete atomically retrieves and removes execution times for a specific block hash +func (c *ExecutionTimeCache) GetAndDelete(blockHash common.Hash) []BeaconExecutionTime { + c.mu.Lock() + defer c.mu.Unlock() + + entry, exists := c.data[blockHash] + if !exists { + return nil + } + + delete(c.data, blockHash) + return entry +} + +// GetStats returns cache statistics +func (c *ExecutionTimeCache) GetStats() (int, int) { + c.mu.RLock() + defer c.mu.RUnlock() + + total := len(c.data) + expired := 0 + now := time.Now() + + for _, entry := range c.data { + for _, execTime := range entry { + if now.Sub(execTime.Added) > c.ttl { + expired++ + } + } + } + + return total, expired +} + +// Close stops the cache cleanup routine +func (c *ExecutionTimeCache) Close() { + close(c.stopCh) + if c.cleanup != nil { + c.cleanup.Stop() + } +} + +// cleanupRoutine periodically removes expired entries +func (c *ExecutionTimeCache) cleanupRoutine() { + for { + select { + case <-c.cleanup.C: + c.performCleanup() + case <-c.stopCh: + return + } + } +} + +// performCleanup removes expired entries from the cache +func (c *ExecutionTimeCache) performCleanup() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + toDelete := make([]common.Hash, 0) + + for hash, entry := range c.data { + maxTime := time.Time{} + for _, execTime := range entry { + if execTime.Added.After(maxTime) { + maxTime = execTime.Added + } + } + if now.Sub(maxTime) > c.ttl { + toDelete = append(toDelete, hash) + } + } + + for _, hash := range toDelete { + delete(c.data, hash) + } +} + +// GetClientTypeID maps client names to type IDs +func GetClientTypeID(clientName string) uint8 { + switch clientName { + case "geth": + return 0 + case "nethermind": + return 1 + case "besu": + return 2 + case "erigon": + return 3 + case "reth": + return 4 + default: + return 255 // Unknown client type + } +} + +// GetClientTypeName maps client type IDs to names +func GetClientTypeName(clientTypeID uint8) string { + switch clientTypeID { + case 0: + return "geth" + case 1: + return "nethermind" + case 2: + return "besu" + case 3: + return "erigon" + case 4: + return "reth" + default: + return "unknown" + } +} diff --git a/indexer/snooper/execution_time_provider.go b/indexer/snooper/execution_time_provider.go new file mode 100644 index 00000000..4bbb92d2 --- /dev/null +++ b/indexer/snooper/execution_time_provider.go @@ -0,0 +1,56 @@ +package snooper + +import ( + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethpandaops/dora/clients/execution" + beacon "github.com/ethpandaops/dora/indexer/beacon" +) + +// ExecutionTimeProviderImpl implements the ExecutionTimeProvider interface +type ExecutionTimeProviderImpl struct { + cache *ExecutionTimeCache +} + +// NewExecutionTimeProvider creates a new execution time provider +func NewExecutionTimeProvider(cache *ExecutionTimeCache) *ExecutionTimeProviderImpl { + return &ExecutionTimeProviderImpl{ + cache: cache, + } +} + +// BeaconExecutionTime represents execution timing data compatible with beacon package +type BeaconExecutionTime struct { + Client *execution.Client // snooper client + Time uint16 // milliseconds + Added time.Time // when this was recorded +} + +// Implement ExecutionTimeData interface +func (e *BeaconExecutionTime) GetClient() *execution.Client { return e.Client } +func (e *BeaconExecutionTime) GetTime() uint16 { return e.Time } + +// GetAndDeleteExecutionTimes implements the ExecutionTimeProvider interface +func (p *ExecutionTimeProviderImpl) GetAndDeleteExecutionTimes(blockHash common.Hash) []beacon.ExecutionTimeData { + if p.cache == nil { + return nil + } + + execTimes := p.cache.GetAndDelete(blockHash) + if execTimes == nil { + return nil + } + + // Convert to beacon package compatible format + result := make([]beacon.ExecutionTimeData, len(execTimes)) + for i, cachedTime := range execTimes { + execTime := &BeaconExecutionTime{ + Client: cachedTime.Client, + Time: cachedTime.Time, + } + result[i] = execTime + } + + return result +} diff --git a/indexer/snooper/snooper_manager.go b/indexer/snooper/snooper_manager.go new file mode 100644 index 00000000..a8180c5e --- /dev/null +++ b/indexer/snooper/snooper_manager.go @@ -0,0 +1,273 @@ +package snooper + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethpandaops/dora/clients/execution" + "github.com/ethpandaops/dora/clients/execution/snooper" + "github.com/ethpandaops/dora/indexer/beacon" + "github.com/ethpandaops/dora/utils" + "github.com/sirupsen/logrus" +) + +// SnooperManager manages snooper clients for execution time tracking +type SnooperManager struct { + logger logrus.FieldLogger + indexer *beacon.Indexer + cache *ExecutionTimeCache + clients map[uint16]*snooperClientInfo + mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + reconnectBackoff time.Duration +} + +// snooperClientInfo holds information about a snooper client +type snooperClientInfo struct { + execution *execution.Client + snooper *snooper.Client + clientID uint16 + snooperURL string + cancel context.CancelFunc + execTimeSubscription *utils.Subscription[*snooper.ExecutionTimeEvent] +} + +// NewSnooperManager creates a new snooper manager +func NewSnooperManager(logger logrus.FieldLogger, indexer *beacon.Indexer) *SnooperManager { + ctx, cancel := context.WithCancel(context.Background()) + + return &SnooperManager{ + logger: logger.WithField("component", "snooper-manager"), + indexer: indexer, + cache: NewExecutionTimeCache(5 * time.Minute), // 5 minute TTL for cached execution times + clients: make(map[uint16]*snooperClientInfo), + ctx: ctx, + cancel: cancel, + reconnectBackoff: 5 * time.Second, + } +} + +// AddClient adds a new snooper client for an execution client +func (sm *SnooperManager) AddClient(executionClient *execution.Client, snooperURL string) error { + if snooperURL == "" { + // No snooper URL configured for this client + return nil + } + + sm.mu.Lock() + defer sm.mu.Unlock() + + clientID := executionClient.GetIndex() + + // Check if client already exists + if _, exists := sm.clients[clientID]; exists { + return fmt.Errorf("snooper client %d already exists", clientID) + } + + sm.logger.WithFields(logrus.Fields{ + "client_id": clientID, + "snooper_url": snooperURL, + }).Debug("adding snooper client") + + // Create new snooper client + snooperClient := snooper.NewClient(snooperURL, clientID, sm.logger) + + // Create client context + clientCtx, clientCancel := context.WithCancel(sm.ctx) + + clientInfo := &snooperClientInfo{ + execution: executionClient, + snooper: snooperClient, + clientID: clientID, + snooperURL: snooperURL, + cancel: clientCancel, + execTimeSubscription: snooperClient.SubscribeExecutionTimeEvent(10, false), + } + + sm.clients[clientID] = clientInfo + + // Start the client in a goroutine with reconnection logic + sm.wg.Add(1) + go sm.runClientWithReconnection(clientCtx, clientInfo) + go sm.runClientEventListener(clientCtx, clientInfo) + + return nil +} + +// RemoveClient removes a snooper client +func (sm *SnooperManager) RemoveClient(clientID uint16) { + sm.mu.Lock() + defer sm.mu.Unlock() + + clientInfo, exists := sm.clients[clientID] + if !exists { + return + } + + sm.logger.WithField("client_id", clientID).Info("removing snooper client") + + // Unsubscribe from execution time events + if clientInfo.execTimeSubscription != nil { + clientInfo.execTimeSubscription.Unsubscribe() + clientInfo.execTimeSubscription = nil + } + + // Cancel the client context + clientInfo.cancel() + + // Close the client + if clientInfo.snooper != nil { + clientInfo.snooper.Close() + } + + delete(sm.clients, clientID) +} + +// GetCache returns the execution time cache +func (sm *SnooperManager) GetCache() *ExecutionTimeCache { + return sm.cache +} + +// HandleExecutionTimeEvent implements the snooper.ExecutionTimeEventHandler interface +func (sm *SnooperManager) HandleExecutionTimeEvent(event *snooper.ExecutionTimeEvent) { + sm.logger.WithFields(logrus.Fields{ + "client_id": event.ClientID, + "block_hash": event.BlockHash.Hex(), + "block_number": event.BlockNumber, + "execution_time": event.ExecutionTime, + }).Debug("received execution time event") + + // Get client type ID for the client + clientInfo, exists := sm.clients[event.ClientID] + if !exists { + return + } + + block := sm.indexer.GetBlocksByExecutionBlockHash(phase0.Hash32(event.BlockHash)) + if len(block) == 0 { + // Store in cache + sm.cache.Set(event.BlockHash, clientInfo.execution, event.ExecutionTime) + } else { + // Update block execution times + for _, b := range block { + b.AddExecutionTime(beacon.ExecutionTime{ + ClientType: clientInfo.execution.GetClientType().Uint8(), + MinTime: uint16(event.ExecutionTime.Milliseconds()), + MaxTime: uint16(event.ExecutionTime.Milliseconds()), + AvgTime: uint16(event.ExecutionTime.Milliseconds()), + Count: 1, + }) + } + } +} + +// Close gracefully shuts down the snooper manager +func (sm *SnooperManager) Close() error { + // Cancel context to stop all clients + sm.cancel() + + // Remove all clients (this will close them) + sm.mu.Lock() + for clientID := range sm.clients { + if clientInfo := sm.clients[clientID]; clientInfo != nil { + clientInfo.cancel() + if clientInfo.snooper != nil { + clientInfo.snooper.Close() + } + } + } + sm.clients = make(map[uint16]*snooperClientInfo) + sm.mu.Unlock() + + // Wait for all goroutines to finish + sm.wg.Wait() + + // Close the cache + sm.cache.Close() + + sm.logger.Info("snooper manager shutdown complete") + return nil +} + +// runClientWithReconnection runs a snooper client with automatic reconnection +func (sm *SnooperManager) runClientWithReconnection(ctx context.Context, clientInfo *snooperClientInfo) { + defer sm.wg.Done() + + logger := sm.logger.WithField("client_id", clientInfo.clientID) + backoffDuration := sm.reconnectBackoff + maxBackoff := 5 * time.Minute + + for { + select { + case <-ctx.Done(): + logger.Debug("client context cancelled, stopping reconnection loop") + return + default: + } + + logger.Debug("attempting to connect snooper client") + + // Try to connect + err := clientInfo.snooper.Connect() + if err != nil { + logger.WithError(err).WithField("backoff", backoffDuration).Warn("failed to connect snooper client, retrying") + + // Wait before retrying + select { + case <-ctx.Done(): + return + case <-time.After(backoffDuration): + } + + // Exponential backoff with jitter + backoffDuration = time.Duration(float64(backoffDuration) * 1.5) + if backoffDuration > maxBackoff { + backoffDuration = maxBackoff + } + continue + } + + // Reset backoff on successful connection + backoffDuration = sm.reconnectBackoff + logger.Debug("snooper client connected successfully") + + // Run the client + err = clientInfo.snooper.Run() + if err != nil && err != context.Canceled { + logger.WithError(err).Warn("Snooper client disconnected") + } + + // Check if we should stop + select { + case <-ctx.Done(): + logger.Debug("client context cancelled, stopping") + return + default: + logger.Debug("snooper client disconnected, will attempt reconnection") + } + } +} + +func (sm *SnooperManager) runClientEventListener(ctx context.Context, clientInfo *snooperClientInfo) { + // listen to execution time events + for { + select { + case <-ctx.Done(): + return + case blockEvent := <-clientInfo.execTimeSubscription.Channel(): + sm.HandleExecutionTimeEvent(blockEvent) + } + } +} + +// HasClients returns true if any snooper clients are configured +func (sm *SnooperManager) HasClients() bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + return len(sm.clients) > 0 +} diff --git a/services/chainservice.go b/services/chainservice.go index 82cccceb..99e0e074 100644 --- a/services/chainservice.go +++ b/services/chainservice.go @@ -20,6 +20,7 @@ import ( "github.com/ethpandaops/dora/indexer/beacon" execindexer "github.com/ethpandaops/dora/indexer/execution" "github.com/ethpandaops/dora/indexer/mevrelay" + "github.com/ethpandaops/dora/indexer/snooper" "github.com/ethpandaops/dora/utils" "github.com/sirupsen/logrus" ) @@ -34,6 +35,7 @@ type ChainService struct { consolidationIndexer *execindexer.ConsolidationIndexer withdrawalIndexer *execindexer.WithdrawalIndexer mevRelayIndexer *mevrelay.MevIndexer + snooperManager *snooper.SnooperManager started bool } @@ -52,6 +54,10 @@ func InitChainService(ctx context.Context, logger logrus.FieldLogger) { chainState := consensusPool.GetChainState() validatorNames := NewValidatorNames(beaconIndexer, chainState) mevRelayIndexer := mevrelay.NewMevIndexer(logger.WithField("service", "mev-relay"), beaconIndexer, chainState) + snooperManager := snooper.NewSnooperManager(logger.WithField("service", "snooper-manager"), beaconIndexer) + + // Set execution time provider + beaconIndexer.SetExecutionTimeProvider(snooper.NewExecutionTimeProvider(snooperManager.GetCache())) GlobalBeaconService = &ChainService{ logger: logger, @@ -60,6 +66,7 @@ func InitChainService(ctx context.Context, logger logrus.FieldLogger) { beaconIndexer: beaconIndexer, validatorNames: validatorNames, mevRelayIndexer: mevRelayIndexer, + snooperManager: snooperManager, } } @@ -129,6 +136,13 @@ func (cs *ChainService) StartService() error { } executionIndexerCtx.AddClientInfo(client, endpoint.Priority, endpoint.Archive) + + // Add snooper client if configured + if endpoint.EngineSnooperUrl != "" { + if err := cs.snooperManager.AddClient(client, endpoint.EngineSnooperUrl); err != nil { + cs.logger.WithError(err).Errorf("could not add snooper client for '%v'", endpoint.Name) + } + } } // initialize blockdb if configured @@ -221,6 +235,11 @@ func (bs *ChainService) StopService() { bs.beaconIndexer = nil } + if bs.snooperManager != nil { + bs.snooperManager.Close() + bs.snooperManager = nil + } + if blockdb.GlobalBlockDb != nil { blockdb.GlobalBlockDb.Close() } @@ -238,6 +257,10 @@ func (bs *ChainService) GetWithdrawalIndexer() *execindexer.WithdrawalIndexer { return bs.withdrawalIndexer } +func (bs *ChainService) GetSnooperManager() *snooper.SnooperManager { + return bs.snooperManager +} + func (bs *ChainService) GetConsensusClients() []*consensus.Client { if bs == nil || bs.consensusPool == nil { return nil diff --git a/templates/blocks/blocks.html b/templates/blocks/blocks.html index 7ab32590..8007682d 100644 --- a/templates/blocks/blocks.html +++ b/templates/blocks/blocks.html @@ -47,6 +47,7 @@

Blocks

+ @@ -86,6 +87,7 @@

Blocks

{{ if .DisplayGasLimit }}Gas Limit{{ end }} {{ if .DisplayBlockSize }}Block Size{{ end }} {{ if .DisplayRecvDelay }}Recv{{ end }} + {{ if .DisplayExecTime }}Exec Time{{ end }} {{ if .DisplayGraffiti }}Graffiti{{ end }} {{ if .DisplayElExtraData }}Extra Data{{ end }} @@ -165,6 +167,13 @@

Blocks

{{ if $g.DisplayGasLimit }}{{ if not (eq $slot.Status 0) }}{{ formatFloat (div (float64 $slot.GasLimit) 1000000) 2 }} M{{ end }}{{ end }} {{ if $g.DisplayBlockSize }}{{ if not (eq $slot.Status 0) }}{{ formatByteAmount $slot.BlockSize }}{{ end }}{{ end }} {{ if $g.DisplayRecvDelay }}{{ if not (eq $slot.Status 0) }}{{ formatRecvDelay $slot.RecvDelay }}{{ end }}{{ end }} + {{ if $g.DisplayExecTime }}{{ if not (eq $slot.Status 0) }} + {{ if gt $slot.MinExecTime 0 }} + + {{ $slot.MaxExecTime }}ms (⌀{{ $slot.AvgExecTime }}ms) + + {{ end }} + {{ end }}{{ end }} {{ if $g.DisplayGraffiti }}{{ if not (eq $slot.Status 0) }}{{ formatGraffiti $slot.Graffiti }}{{ end }}{{ end }} {{ if $g.DisplayElExtraData }}{{ if not (eq $slot.Status 0) }}{{ formatGraffiti $slot.ElExtraData }}{{ end }}{{ end }} {{ else }} @@ -180,6 +189,7 @@

Blocks

{{ if $g.DisplayGasLimit }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if $g.DisplayBlockSize }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if $g.DisplayRecvDelay }}{{ $colCount = add $colCount 1 }}{{ end }} + {{ if $g.DisplayExecTime }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if $g.DisplayGraffiti }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if $g.DisplayElExtraData }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if gt $colCount 0 }}Not indexed yet{{ end }} @@ -275,6 +285,62 @@

Blocks

} }); }); + + // Initialize popovers for execution times + $('.exec-time-display').each(function() { + var $el = $(this); + var execTimes = $el.data('exec-times'); + var minTime = $el.data('min'); + var maxTime = $el.data('max'); + var avgTime = $el.data('avg'); + + var content = '
'; + + // Overall stats section + var totalCount = 0; + if (execTimes && execTimes.length > 0) { + execTimes.forEach(function(et) { + totalCount += et.count; + }); + } + + content += '
'; + content += '
Range: ' + minTime + ' - ' + maxTime + ' ms
'; + content += '
Average: ' + avgTime + ' ms (' + totalCount + ' samples)
'; + content += '
'; + + if (execTimes && execTimes.length > 0) { + content += '
'; + content += '
'; + content += 'ClientMinMaxAvgCount'; + content += '
'; + + execTimes.forEach(function(et) { + content += '
'; + content += '' + et.client_type + ''; + content += '' + et.min_time + 'ms'; + content += '' + et.max_time + 'ms'; + content += '' + et.avg_time + 'ms'; + content += '' + et.count + ''; + content += '
'; + }); + + content += '
'; + } else { + content += '
No detailed client breakdown available
'; + } + + content += '
'; + + $el.popover({ + html: true, + trigger: 'hover', + placement: 'top', + content: content, + container: 'body', + template: '' + }); + }); }); {{ end }} @@ -300,5 +366,53 @@

Blocks

.filter-multiselect-container .multiselect-container>li>a>label>input { margin: 0 4px; } + .exec-time-display { + cursor: pointer; + text-decoration: underline dotted; + } + .exec-time-popover { + min-width: 400px; + max-width: 500px; + font-size: 0.875rem; + } + .exec-time-popover .overall-stats { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .overall-stats div { + margin-bottom: 4px; + } + .exec-time-popover .client-breakdown { + display: grid; + grid-template-columns: 1fr 70px 70px 70px 60px; + gap: 10px; + } + .exec-time-popover .breakdown-header { + display: contents; + font-weight: bold; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .breakdown-header span { + padding-bottom: 4px; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .breakdown-row { + display: contents; + } + .exec-time-popover .breakdown-row span { + padding: 2px 0; + } + .exec-time-popover .no-data { + color: #6c757d; + font-style: italic; + margin-top: 8px; + } + .exec-time-popover-container { + max-width: 500px !important; + } + .exec-time-popover-container .popover-body { + padding: 12px; + } {{ end }} \ No newline at end of file diff --git a/templates/slots/slots.html b/templates/slots/slots.html index fa5690d9..df8a7943 100644 --- a/templates/slots/slots.html +++ b/templates/slots/slots.html @@ -47,6 +47,7 @@

Slots

+ @@ -86,6 +87,7 @@

Slots

{{ if .DisplayGasLimit }}Gas Limit{{ end }} {{ if .DisplayBlockSize }}Block Size{{ end }} {{ if .DisplayRecvDelay }}Recv{{ end }} + {{ if .DisplayExecTime }}Exec Time{{ end }} {{ if .DisplayGraffiti }}Graffiti{{ end }} {{ if .DisplayElExtraData }}Extra Data{{ end }} @@ -165,6 +167,13 @@

Slots

{{ if $g.DisplayGasLimit }}{{ if not (eq $slot.Status 0) }}{{ formatFloat (div (float64 $slot.GasLimit) 1000000) 2 }} M{{ end }}{{ end }} {{ if $g.DisplayBlockSize }}{{ if not (eq $slot.Status 0) }}{{ formatByteAmount $slot.BlockSize }}{{ end }}{{ end }} {{ if $g.DisplayRecvDelay }}{{ if not (eq $slot.Status 0) }}{{ formatRecvDelay $slot.RecvDelay }}{{ end }}{{ end }} + {{ if $g.DisplayExecTime }}{{ if not (eq $slot.Status 0) }} + {{ if gt $slot.MinExecTime 0 }} + + {{ $slot.MaxExecTime }}ms (⌀{{ $slot.AvgExecTime }}ms) + + {{ end }} + {{ end }}{{ end }} {{ if $g.DisplayGraffiti }}{{ if not (eq $slot.Status 0) }}{{ formatGraffiti $slot.Graffiti }}{{ end }}{{ end }} {{ if $g.DisplayElExtraData }}{{ if not (eq $slot.Status 0) }}{{ formatGraffiti $slot.ElExtraData }}{{ end }}{{ end }} {{ else }} @@ -180,6 +189,7 @@

Slots

{{ if $g.DisplayGasLimit }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if $g.DisplayBlockSize }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if $g.DisplayRecvDelay }}{{ $colCount = add $colCount 1 }}{{ end }} + {{ if $g.DisplayExecTime }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if $g.DisplayGraffiti }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if $g.DisplayElExtraData }}{{ $colCount = add $colCount 1 }}{{ end }} {{ if gt $colCount 0 }}Not indexed yet{{ end }} @@ -275,6 +285,62 @@

Slots

} }); }); + + // Initialize popovers for execution times + $('.exec-time-display').each(function() { + var $el = $(this); + var execTimes = $el.data('exec-times'); + var minTime = $el.data('min'); + var maxTime = $el.data('max'); + var avgTime = $el.data('avg'); + + var content = '
'; + + // Overall stats section + var totalCount = 0; + if (execTimes && execTimes.length > 0) { + execTimes.forEach(function(et) { + totalCount += et.count; + }); + } + + content += '
'; + content += '
Range: ' + minTime + ' - ' + maxTime + ' ms
'; + content += '
Average: ' + avgTime + ' ms (' + totalCount + ' samples)
'; + content += '
'; + + if (execTimes && execTimes.length > 0) { + content += '
'; + content += '
'; + content += 'ClientMinMaxAvgCount'; + content += '
'; + + execTimes.forEach(function(et) { + content += '
'; + content += '' + et.client_type + ''; + content += '' + et.min_time + 'ms'; + content += '' + et.max_time + 'ms'; + content += '' + et.avg_time + 'ms'; + content += '' + et.count + ''; + content += '
'; + }); + + content += '
'; + } else { + content += '
No detailed client breakdown available
'; + } + + content += '
'; + + $el.popover({ + html: true, + trigger: 'hover', + placement: 'top', + content: content, + container: 'body', + template: '' + }); + }); }); {{ end }} @@ -300,5 +366,53 @@

Slots

.filter-multiselect-container .multiselect-container>li>a>label>input { margin: 0 4px; } + .exec-time-display { + cursor: pointer; + text-decoration: underline dotted; + } + .exec-time-popover { + min-width: 400px; + max-width: 500px; + font-size: 0.875rem; + } + .exec-time-popover .overall-stats { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .overall-stats div { + margin-bottom: 4px; + } + .exec-time-popover .client-breakdown { + display: grid; + grid-template-columns: 1fr 70px 70px 70px 60px; + gap: 10px; + } + .exec-time-popover .breakdown-header { + display: contents; + font-weight: bold; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .breakdown-header span { + padding-bottom: 4px; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .breakdown-row { + display: contents; + } + .exec-time-popover .breakdown-row span { + padding: 2px 0; + } + .exec-time-popover .no-data { + color: #6c757d; + font-style: italic; + margin-top: 8px; + } + .exec-time-popover-container { + max-width: 500px !important; + } + .exec-time-popover-container .popover-body { + padding: 12px; + } {{ end }} \ No newline at end of file diff --git a/templates/slots_filtered/slots_filtered.html b/templates/slots_filtered/slots_filtered.html index 43a6d49e..ebd6b333 100644 --- a/templates/slots_filtered/slots_filtered.html +++ b/templates/slots_filtered/slots_filtered.html @@ -136,6 +136,7 @@

Filtered Slots

+ @@ -187,6 +188,7 @@

Filtered Slots

{{ if .DisplayGasLimit }}Gas Limit{{ end }} {{ if .DisplayBlockSize }}Block Size{{ end }} {{ if .DisplayRecvDelay }}Recv{{ end }} + {{ if .DisplayExecTime }}Exec Time{{ end }} {{ if .DisplayGraffiti }}Graffiti{{ end }} {{ if .DisplayElExtraData }}Extra Data{{ end }} @@ -274,6 +276,15 @@

Filtered Slots

{{- if $g.DisplayRecvDelay }} {{ if not (eq $slot.Status 0) }}{{ formatRecvDelay $slot.RecvDelay }}{{ end }} {{- end }} + {{- if $g.DisplayExecTime }} + {{ if not (eq $slot.Status 0) }} + {{ if gt $slot.MinExecTime 0 }} + + {{ $slot.MaxExecTime }}ms (⌀{{ $slot.AvgExecTime }}ms) + + {{ end }} + {{ end }} + {{- end }} {{- if $g.DisplayGraffiti }} {{ if not (eq $slot.Status 0) }}{{ formatGraffiti $slot.Graffiti }}{{ end }} {{- end }} @@ -356,6 +367,62 @@

Filtered Slots

} }); }); + + // Initialize popovers for execution times + $('.exec-time-display').each(function() { + var $el = $(this); + var execTimes = $el.data('exec-times'); + var minTime = $el.data('min'); + var maxTime = $el.data('max'); + var avgTime = $el.data('avg'); + + var content = '
'; + + // Overall stats section + var totalCount = 0; + if (execTimes && execTimes.length > 0) { + execTimes.forEach(function(et) { + totalCount += et.count; + }); + } + + content += '
'; + content += '
Range: ' + minTime + ' - ' + maxTime + ' ms
'; + content += '
Average: ' + avgTime + ' ms (' + totalCount + ' samples)
'; + content += '
'; + + if (execTimes && execTimes.length > 0) { + content += '
'; + content += '
'; + content += 'ClientMinMaxAvgCount'; + content += '
'; + + execTimes.forEach(function(et) { + content += '
'; + content += '' + et.client_type + ''; + content += '' + et.min_time + 'ms'; + content += '' + et.max_time + 'ms'; + content += '' + et.avg_time + 'ms'; + content += '' + et.count + ''; + content += '
'; + }); + + content += '
'; + } else { + content += '
No detailed client breakdown available
'; + } + + content += '
'; + + $el.popover({ + html: true, + trigger: 'hover', + placement: 'top', + content: content, + container: 'body', + template: '' + }); + }); }); {{ end }} @@ -382,5 +449,53 @@

Filtered Slots

.gas-usage-progress { height: 5px; } + .exec-time-display { + cursor: pointer; + text-decoration: underline dotted; + } + .exec-time-popover { + min-width: 400px; + max-width: 500px; + font-size: 0.875rem; + } + .exec-time-popover .overall-stats { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .overall-stats div { + margin-bottom: 4px; + } + .exec-time-popover .client-breakdown { + display: grid; + grid-template-columns: 1fr 70px 70px 70px 60px; + gap: 10px; + } + .exec-time-popover .breakdown-header { + display: contents; + font-weight: bold; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .breakdown-header span { + padding-bottom: 4px; + border-bottom: 1px solid #dee2e6; + } + .exec-time-popover .breakdown-row { + display: contents; + } + .exec-time-popover .breakdown-row span { + padding: 2px 0; + } + .exec-time-popover .no-data { + color: #6c757d; + font-style: italic; + margin-top: 8px; + } + .exec-time-popover-container { + max-width: 500px !important; + } + .exec-time-popover-container .popover-body { + padding: 12px; + } {{ end }} \ No newline at end of file diff --git a/types/config.go b/types/config.go index 2a556c82..7e297120 100644 --- a/types/config.go +++ b/types/config.go @@ -135,13 +135,14 @@ type Config struct { } type EndpointConfig struct { - Ssh *EndpointSshConfig `yaml:"ssh"` - Url string `yaml:"url"` - Name string `yaml:"name"` - Archive bool `yaml:"archive"` - SkipValidators bool `yaml:"skipValidators"` - Priority int `yaml:"priority"` - Headers map[string]string `yaml:"headers"` + Ssh *EndpointSshConfig `yaml:"ssh"` + Url string `yaml:"url"` + Name string `yaml:"name"` + Archive bool `yaml:"archive"` + SkipValidators bool `yaml:"skipValidators"` + Priority int `yaml:"priority"` + Headers map[string]string `yaml:"headers"` + EngineSnooperUrl string `yaml:"engineSnooperUrl"` } type EndpointSshConfig struct { diff --git a/types/models/blocks.go b/types/models/blocks.go index 6f0f3638..3b167e8b 100644 --- a/types/models/blocks.go +++ b/types/models/blocks.go @@ -30,6 +30,7 @@ type BlocksPageData struct { DisplayMevBlock bool `json:"dp_mevblock"` DisplayBlockSize bool `json:"dp_blocksize"` DisplayRecvDelay bool `json:"dp_recvdelay"` + DisplayExecTime bool `json:"dp_exectime"` DisplayColCount uint64 `json:"display_col_count"` IsDefaultPage bool `json:"default_page"` @@ -77,6 +78,10 @@ type BlocksPageDataSlot struct { BlockRoot []byte `json:"block_root"` ParentRoot []byte `json:"parent_root"` RecvDelay int32 `json:"recv_delay"` + MinExecTime uint32 `json:"min_exec_time"` + MaxExecTime uint32 `json:"max_exec_time"` + AvgExecTime uint32 `json:"avg_exec_time"` + ExecutionTimes []ExecutionTimeDetail `json:"execution_times"` ForkGraph []*BlocksPageDataForkGraph `json:"fork_graph"` IsMevBlock bool `json:"is_mev_block"` MevBlockRelays string `json:"mev_block_relays"` diff --git a/types/models/slots.go b/types/models/slots.go index 92fa3c5b..6c91b2f4 100644 --- a/types/models/slots.go +++ b/types/models/slots.go @@ -30,6 +30,7 @@ type SlotsPageData struct { DisplayMevBlock bool `json:"dp_mevblock"` DisplayBlockSize bool `json:"dp_blocksize"` DisplayRecvDelay bool `json:"dp_recvdelay"` + DisplayExecTime bool `json:"dp_exectime"` DisplayColCount uint64 `json:"display_col_count"` IsDefaultPage bool `json:"default_page"` @@ -77,6 +78,10 @@ type SlotsPageDataSlot struct { BlockRoot []byte `json:"block_root"` ParentRoot []byte `json:"parent_root"` RecvDelay int32 `json:"recv_delay"` + MinExecTime uint32 `json:"min_exec_time"` + MaxExecTime uint32 `json:"max_exec_time"` + AvgExecTime uint32 `json:"avg_exec_time"` + ExecutionTimes []ExecutionTimeDetail `json:"execution_times"` ForkGraph []*SlotsPageDataForkGraph `json:"fork_graph"` IsMevBlock bool `json:"is_mev_block"` MevBlockRelays string `json:"mev_block_relays"` @@ -88,3 +93,11 @@ type SlotsPageDataForkGraph struct { Tiles map[string]bool `json:"tiles"` Block bool `json:"block"` } + +type ExecutionTimeDetail struct { + ClientType string `json:"client_type"` + MinTime uint16 `json:"min_time"` + MaxTime uint16 `json:"max_time"` + AvgTime uint16 `json:"avg_time"` + Count uint16 `json:"count"` +} diff --git a/types/models/slots_filtered.go b/types/models/slots_filtered.go index 501d3e72..e4f77b4b 100644 --- a/types/models/slots_filtered.go +++ b/types/models/slots_filtered.go @@ -33,6 +33,7 @@ type SlotsFilteredPageData struct { DisplayMevBlock bool `json:"dp_mevblock"` DisplayBlockSize bool `json:"dp_blocksize"` DisplayRecvDelay bool `json:"dp_recvdelay"` + DisplayExecTime bool `json:"dp_exectime"` DisplayColCount uint64 `json:"display_col_count"` Slots []*SlotsFilteredPageDataSlot `json:"slots"` @@ -58,33 +59,37 @@ type SlotsFilteredPageData struct { } type SlotsFilteredPageDataSlot struct { - Slot uint64 `json:"slot"` - Epoch uint64 `json:"epoch"` - Ts time.Time `json:"ts"` - Finalized bool `json:"scheduled"` - Scheduled bool `json:"finalized"` - Status uint8 `json:"status"` - Synchronized bool `json:"synchronized"` - Proposer uint64 `json:"proposer"` - ProposerName string `json:"proposer_name"` - AttestationCount uint64 `json:"attestation_count"` - DepositCount uint64 `json:"deposit_count"` - ExitCount uint64 `json:"exit_count"` - ProposerSlashingCount uint64 `json:"proposer_slashing_count"` - AttesterSlashingCount uint64 `json:"attester_slashing_count"` - SyncParticipation float64 `json:"sync_participation"` - EthTransactionCount uint64 `json:"eth_transaction_count"` - BlobCount uint64 `json:"blob_count"` - WithEthBlock bool `json:"with_eth_block"` - EthBlockNumber uint64 `json:"eth_block_number"` - Graffiti []byte `json:"graffiti"` - ElExtraData []byte `json:"el_extra_data"` - GasUsed uint64 `json:"gas_used"` - GasLimit uint64 `json:"gas_limit"` - BlockSize uint64 `json:"block_size"` - BlockRoot []byte `json:"block_root"` - ParentRoot []byte `json:"parent_root"` - RecvDelay int32 `json:"recv_delay"` - IsMevBlock bool `json:"is_mev_block"` - MevBlockRelays string `json:"mev_block_relays"` + Slot uint64 `json:"slot"` + Epoch uint64 `json:"epoch"` + Ts time.Time `json:"ts"` + Finalized bool `json:"scheduled"` + Scheduled bool `json:"finalized"` + Status uint8 `json:"status"` + Synchronized bool `json:"synchronized"` + Proposer uint64 `json:"proposer"` + ProposerName string `json:"proposer_name"` + AttestationCount uint64 `json:"attestation_count"` + DepositCount uint64 `json:"deposit_count"` + ExitCount uint64 `json:"exit_count"` + ProposerSlashingCount uint64 `json:"proposer_slashing_count"` + AttesterSlashingCount uint64 `json:"attester_slashing_count"` + SyncParticipation float64 `json:"sync_participation"` + EthTransactionCount uint64 `json:"eth_transaction_count"` + BlobCount uint64 `json:"blob_count"` + WithEthBlock bool `json:"with_eth_block"` + EthBlockNumber uint64 `json:"eth_block_number"` + Graffiti []byte `json:"graffiti"` + ElExtraData []byte `json:"el_extra_data"` + GasUsed uint64 `json:"gas_used"` + GasLimit uint64 `json:"gas_limit"` + BlockSize uint64 `json:"block_size"` + BlockRoot []byte `json:"block_root"` + ParentRoot []byte `json:"parent_root"` + RecvDelay int32 `json:"recv_delay"` + MinExecTime uint32 `json:"min_exec_time"` + MaxExecTime uint32 `json:"max_exec_time"` + AvgExecTime uint32 `json:"avg_exec_time"` + ExecutionTimes []ExecutionTimeDetail `json:"execution_times"` + IsMevBlock bool `json:"is_mev_block"` + MevBlockRelays string `json:"mev_block_relays"` } diff --git a/clients/consensus/subscriptions.go b/utils/subscriptions.go similarity index 98% rename from clients/consensus/subscriptions.go rename to utils/subscriptions.go index be56d546..40284e97 100644 --- a/clients/consensus/subscriptions.go +++ b/utils/subscriptions.go @@ -1,4 +1,4 @@ -package consensus +package utils import "sync"