- 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.
216 lines
6.2 KiB
Go
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)
|
|
}
|