Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8b95512
feat: add ValidatorSet and ValsetChanges to ChainHealthSnapshot
louis14448 Apr 20, 2026
2b51c7c
feat: add per-validator precommit bitmap from DumpConsensusState
louis14448 Apr 20, 2026
8cefe20
feat: add PeerCount and MempoolTxCount to ChainHealthSnapshot
louis14448 Apr 20, 2026
a80d24f
feat: enrich ValidatorInfo with KeepRunning and ServerType from valop…
louis14448 Apr 20, 2026
3dceb8a
feat: enrich FormatHealthyReport, FormatStuckReport and /status with …
louis14448 Apr 20, 2026
f410d95
feat: add voting_power, peer_count, mempool_tx_count and valset_size …
louis14448 Apr 20, 2026
1921ff1
feat: add GET /api/chain/:chainID/health endpoint
louis14448 Apr 20, 2026
5988f69
fix: remove goroutine leak in enrichValidatorInfoFromValopers
louis14448 Apr 20, 2026
9d3b1c8
feat: replace precommit bitmap with participation rate in report form…
louis14448 Apr 20, 2026
9e81627
feat: update Telegram /status formatter with participation rate and p…
louis14448 Apr 20, 2026
2bd4c4d
feat: compute validator uptime over last 24h instead of last N blocks
louis14448 Apr 20, 2026
cb4b6a8
feat: label validator section as last 24h in Telegram /status
louis14448 Apr 20, 2026
5192848
feat: remove infrastructure section — valopers ServerType too sparsel…
louis14448 Apr 20, 2026
81b6c53
fix: unwrap amino-encoded peer_state string before JSON decode in Dum…
louis14448 Apr 20, 2026
e05077f
cleanup
louis14448 Apr 20, 2026
f5846ba
fix: rewrite parseValsetChanges to match actual bullet-list format of…
louis14448 Apr 20, 2026
c721bda
fix: use DeletePartialMatch instead of Reset for ValidatorVotingPower…
louis14448 Apr 20, 2026
9214b8f
fix: add 20s timeout on /health endpoint to prevent WriteTimeout breach
louis14448 Apr 20, 2026
374dfd3
fix: base64-decode peer_state after amino string unwrap in DumpConsen…
louis14448 Apr 20, 2026
e7e92f9
feat: filter valset changes to last 24h window using snap.MinBlock
louis14448 Apr 20, 2026
32eb5a4
fix: use json.Number for bitArrayJSON.Bits to handle amino string-enc…
louis14448 Apr 20, 2026
5882ff5
fix: custom UnmarshalJSON for bitArrayJSON to handle amino string-enc…
louis14448 Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions backend/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"net/http"
"strconv"
"strings"
"time"

clerkhttp "github.com/clerk/clerk-sdk-go/v2/http"
Expand Down Expand Up @@ -990,6 +991,108 @@ func GetAddrMonikerHandler(w http.ResponseWriter, r *http.Request, db *gorm.DB)
json.NewEncoder(w).Encode(map[string]string{"addr": addr, "moniker": moniker})
}

// =========================== Chain Health Snapshot ==============================

type chainHealthValidatorJSON struct {
Address string `json:"address"`
VotingPower int64 `json:"voting_power"`
KeepRunning bool `json:"keep_running"`
ServerType string `json:"server_type"`
}

type chainHealthValsetChangeJSON struct {
BlockNum int64 `json:"block_num"`
Address string `json:"address"`
NewPower int64 `json:"new_power"`
}

type chainHealthResponse struct {
RPCReachable bool `json:"rpc_reachable"`
IsStuck bool `json:"is_stuck"`
IsDisabled bool `json:"is_disabled"`
LatestBlockHeight int64 `json:"latest_block_height"`
LatestBlockTime string `json:"latest_block_time,omitempty"`
ConsensusRound int `json:"consensus_round"`
PeerCount int `json:"peer_count"`
MempoolTxCount int `json:"mempool_tx_count"`
MempoolTotalBytes int64 `json:"mempool_total_bytes"`
ValidatorSet []chainHealthValidatorJSON `json:"validator_set,omitempty"`
ValsetChanges []chainHealthValsetChangeJSON `json:"valset_changes,omitempty"`
PrecommitBitmap map[string]bool `json:"precommit_bitmap,omitempty"`
}

func GetChainHealth(w http.ResponseWriter, r *http.Request, db *gorm.DB, chainID string) {
EnableCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := internal.Config.ValidateChainID(chainID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
type snapResult struct {
snap gnovalidator.ChainHealthSnapshot
}
ch := make(chan snapResult, 1)
go func() {
ch <- snapResult{snap: gnovalidator.FetchChainHealthSnapshot(db, chainID)}
}()
var snap gnovalidator.ChainHealthSnapshot
select {
case res := <-ch:
snap = res.snap
case <-time.After(20 * time.Second):
http.Error(w, "health snapshot timed out", http.StatusGatewayTimeout)
return
}

resp := chainHealthResponse{
RPCReachable: snap.RPCReachable,
IsStuck: snap.IsStuck,
IsDisabled: snap.IsDisabled,
LatestBlockHeight: snap.LatestBlockHeight,
ConsensusRound: snap.ConsensusRound,
PeerCount: snap.PeerCount,
MempoolTxCount: snap.MempoolTxCount,
MempoolTotalBytes: snap.MempoolTotalBytes,
PrecommitBitmap: snap.PrecommitBitmap,
}
if !snap.LatestBlockTime.IsZero() {
resp.LatestBlockTime = snap.LatestBlockTime.UTC().Format(time.RFC3339)
}
if len(snap.ValidatorSet) > 0 {
vs := make([]chainHealthValidatorJSON, 0, len(snap.ValidatorSet))
for _, vi := range snap.ValidatorSet {
vs = append(vs, chainHealthValidatorJSON{
Address: vi.Address,
VotingPower: vi.VotingPower,
KeepRunning: vi.KeepRunning,
ServerType: vi.ServerType,
})
}
resp.ValidatorSet = vs
}
if len(snap.ValsetChanges) > 0 {
vc := make([]chainHealthValsetChangeJSON, 0, len(snap.ValsetChanges))
for _, c := range snap.ValsetChanges {
vc = append(vc, chainHealthValsetChangeJSON{
BlockNum: c.BlockNum,
Address: c.Address,
NewPower: c.NewPower,
})
}
resp.ValsetChanges = vc
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}

// ======================CORS=============================================
func EnableCORS(w http.ResponseWriter, r ...*http.Request) {
origin := ""
Expand Down Expand Up @@ -1270,6 +1373,24 @@ func StartWebhookAPI(db *gorm.DB) {
}
})

// /api/chain/<chainID>/health
mux.HandleFunc("/api/chain/", func(w http.ResponseWriter, r *http.Request) {
// Expected path: /api/chain/<chainID>/health
// Strip the prefix "/api/chain/" to get "<chainID>/health"
rest := strings.TrimPrefix(r.URL.Path, "/api/chain/")
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 || parts[1] != "health" {
http.NotFound(w, r)
return
}
chainID := parts[0]
if chainID == "" {
http.Error(w, "Missing chain ID", http.StatusBadRequest)
return
}
GetChainHealth(w, r, db, chainID)
})

// Starting the HTTP server -
addr := ":" + internal.Config.BackendPort

Expand Down
56 changes: 56 additions & 0 deletions backend/internal/gnovalidator/Prometheus.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,39 @@ var (
[]string{"chain", "level"},
)

// Phase 4: RPC-enriched metrics
ValidatorVotingPower = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "gnoland_validator_voting_power",
Help: "Current voting power of each validator",
},
[]string{"chain", "validator_address", "moniker"},
)

ChainPeerCount = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "gnoland_chain_peer_count",
Help: "Number of connected peers",
},
[]string{"chain"},
)

ChainMempoolTxCount = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "gnoland_chain_mempool_tx_count",
Help: "Pending transactions in mempool",
},
[]string{"chain"},
)

ChainValsetSize = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "gnoland_chain_valset_size",
Help: "Number of active validators in current set",
},
[]string{"chain"},
)

initOnce sync.Once
)

Expand All @@ -163,6 +196,11 @@ func Init() {
// Phase 3: Alert metrics
prometheus.MustRegister(ActiveAlerts)
prometheus.MustRegister(AlertsTotal)
// Phase 4: RPC-enriched metrics
prometheus.MustRegister(ValidatorVotingPower)
prometheus.MustRegister(ChainPeerCount)
prometheus.MustRegister(ChainMempoolTxCount)
prometheus.MustRegister(ChainValsetSize)
})
}

Expand Down Expand Up @@ -374,6 +412,24 @@ func UpdatePrometheusMetricsFromDB(db *gorm.DB, chainID string, ctxOpts ...conte
AlertsTotal.WithLabelValues(chainID, level).Set(float64(totalCount))
}

// Phase 4: RPC-enriched metrics (voting power, peer count, mempool, valset size)
snap := FetchChainHealthSnapshot(db, chainID)
if snap.RPCReachable {
monikerMap := GetMonikerMap(chainID)

ValidatorVotingPower.DeletePartialMatch(chainLabel)
for _, v := range snap.ValidatorSet {
moniker := monikerMap[v.Address]
ValidatorVotingPower.WithLabelValues(chainID, v.Address, moniker).Set(float64(v.VotingPower))
}

ChainPeerCount.WithLabelValues(chainID).Set(float64(snap.PeerCount))
ChainMempoolTxCount.WithLabelValues(chainID).Set(float64(snap.MempoolTxCount))
ChainValsetSize.WithLabelValues(chainID).Set(float64(len(snap.ValidatorSet)))
} else {
log.Printf("[metrics][%s] RPC unreachable, skipping Phase 4 metrics", chainID)
}

log.Printf("[metrics][%s] update complete", chainID)
return nil
}
Expand Down
Loading
Loading