Files
explorer-monorepo/backend/api/rest/stats.go
defiQUG 0c869f7930 feat(freshness): enhance diagnostics and update snapshot structure
- Introduced a new Diagnostics struct to capture transaction visibility state and activity state.
- Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling.
- Enhanced test cases to validate the new diagnostics data.
- Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context.

This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data.
2026-04-12 18:22:08 -07:00

216 lines
6.2 KiB
Go

package rest
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/explorer/backend/api/freshness"
)
type explorerStats struct {
TotalBlocks int64 `json:"total_blocks"`
TotalTransactions int64 `json:"total_transactions"`
TotalAddresses int64 `json:"total_addresses"`
LatestBlock int64 `json:"latest_block"`
AverageBlockTime *float64 `json:"average_block_time,omitempty"`
GasPrices *explorerGasPrices `json:"gas_prices,omitempty"`
NetworkUtilizationPercentage *float64 `json:"network_utilization_percentage,omitempty"`
TransactionsToday *int64 `json:"transactions_today,omitempty"`
Freshness freshness.Snapshot `json:"freshness"`
Completeness freshness.SummaryCompleteness `json:"completeness"`
Sampling freshness.Sampling `json:"sampling"`
Diagnostics freshness.Diagnostics `json:"diagnostics"`
}
type explorerGasPrices struct {
Average *float64 `json:"average,omitempty"`
}
type statsQueryFunc = freshness.QueryRowFunc
func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) {
var value sql.NullFloat64
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
return nil, err
}
if !value.Valid {
return nil, nil
}
return &value.Float64, nil
}
func queryNullableInt64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*int64, error) {
var value sql.NullInt64
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
return nil, err
}
if !value.Valid {
return nil, nil
}
return &value.Int64, nil
}
func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc) (explorerStats, error) {
var stats explorerStats
_ = chainID
if err := queryRow(ctx,
`SELECT COUNT(*) FROM blocks`,
).Scan(&stats.TotalBlocks); err != nil {
return explorerStats{}, fmt.Errorf("query total blocks: %w", err)
}
if err := queryRow(ctx,
`SELECT COUNT(*) FROM transactions WHERE block_hash IS NOT NULL`,
).Scan(&stats.TotalTransactions); err != nil {
return explorerStats{}, fmt.Errorf("query total transactions: %w", err)
}
if err := queryRow(ctx,
`SELECT COUNT(*) FROM (
SELECT from_address_hash AS address
FROM transactions
WHERE from_address_hash IS NOT NULL
UNION
SELECT to_address_hash AS address
FROM transactions
WHERE to_address_hash IS NOT NULL
) unique_addresses`,
).Scan(&stats.TotalAddresses); err != nil {
return explorerStats{}, fmt.Errorf("query total addresses: %w", err)
}
if err := queryRow(ctx,
`SELECT COALESCE(MAX(number), 0) FROM blocks`,
).Scan(&stats.LatestBlock); err != nil {
return explorerStats{}, fmt.Errorf("query latest block: %w", err)
}
statsIssues := map[string]string{}
averageBlockTime, err := queryNullableFloat64(ctx, queryRow,
`SELECT CASE
WHEN COUNT(*) >= 2
THEN (EXTRACT(EPOCH FROM (MAX(timestamp) - MIN(timestamp))) * 1000.0) / NULLIF(COUNT(*) - 1, 0)
ELSE NULL
END
FROM (
SELECT timestamp
FROM blocks
ORDER BY number DESC
LIMIT 100
) recent_blocks`,
)
if err != nil {
statsIssues["average_block_time"] = err.Error()
} else {
stats.AverageBlockTime = averageBlockTime
}
averageGasPrice, err := queryNullableFloat64(ctx, queryRow,
`SELECT AVG(gas_price_wei)::double precision / 1000000000.0
FROM (
SELECT gas_price AS gas_price_wei
FROM transactions
WHERE block_hash IS NOT NULL
AND gas_price IS NOT NULL
ORDER BY block_number DESC, "index" DESC
LIMIT 1000
) recent_transactions`,
)
if err != nil {
statsIssues["average_gas_price"] = err.Error()
} else if averageGasPrice != nil {
stats.GasPrices = &explorerGasPrices{Average: averageGasPrice}
}
networkUtilization, err := queryNullableFloat64(ctx, queryRow,
`SELECT AVG((gas_used::double precision / NULLIF(gas_limit, 0)) * 100.0)
FROM (
SELECT gas_used, gas_limit
FROM blocks
WHERE gas_limit IS NOT NULL
AND gas_limit > 0
ORDER BY number DESC
LIMIT 100
) recent_blocks`,
)
if err != nil {
statsIssues["network_utilization_percentage"] = err.Error()
} else {
stats.NetworkUtilizationPercentage = networkUtilization
}
transactionsToday, err := queryNullableInt64(ctx, queryRow,
`SELECT COUNT(*)::bigint
FROM transactions t
JOIN blocks b
ON b.number = t.block_number
WHERE b.timestamp >= NOW() - INTERVAL '24 hours'`,
)
if err != nil {
statsIssues["transactions_today"] = err.Error()
} else {
stats.TransactionsToday = transactionsToday
}
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
ctx,
chainID,
queryRow,
func(ctx context.Context) (*freshness.Reference, error) {
return freshness.ProbeChainHead(ctx, rpcURL)
},
time.Now().UTC(),
averageGasPrice,
networkUtilization,
)
if err != nil {
return explorerStats{}, fmt.Errorf("build freshness snapshot: %w", err)
}
if len(statsIssues) > 0 {
if sampling.Issues == nil {
sampling.Issues = map[string]string{}
}
for key, value := range statsIssues {
sampling.Issues[key] = value
}
}
stats.Freshness = snapshot
stats.Completeness = completeness
stats.Sampling = sampling
stats.Diagnostics = diagnostics
return stats, nil
}
// handleStats handles GET /api/v2/stats
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if !s.requireDB(w) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}