Files

214 lines
6.0 KiB
Go
Raw Permalink Normal View History

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"`
}
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, 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
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)
}