Files
explorer/stats.go
T
zeekay 48f3ebc0ab chore: sync working tree
Commits 3 outstanding change(s) that were sitting uncommitted.
No build artifacts and no secrets in the changeset (both checked).
2026-07-26 10:13:18 -07:00

267 lines
9.5 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// StatsService serves the Blockscout stats-microservice API surface
// (/v1/counters, /v1/lines, /v1/lines/{id}, /v1/pages/*) for the default chain,
// backed directly by the live indexer SQLite DB.
//
// The Next.js explorer turns on config.features.stats when
// NEXT_PUBLIC_STATS_API_HOST is set (it points at this backend). Its /stats page
// then requests /v1/counters and /v1/lines; without them it shows two error
// banners ("Something went wrong" + "Some of the charts did not load because the
// server didn't respond"). The indexer's own /v1/indexer/{slug}/stats endpoint
// is a different shape and its chart endpoints are empty stubs, so we serve real
// aggregates here, computed from evm_blocks / evm_transactions / evm_addresses.
type StatsService struct {
dbPath string
mu sync.Mutex
db *sql.DB
}
// NewStatsService targets the default chain's indexer DB
// ({dataDir}/{slug}/query/indexer.db).
func NewStatsService(dbPath string) *StatsService {
return &StatsService{dbPath: dbPath}
}
// conn lazily opens a read-only handle. The indexer writes in WAL mode, so a
// read-only reader never blocks ingestion. The DB may not exist until the
// indexer has created it, so open is retried per-request until it succeeds.
func (s *StatsService) conn() (*sql.DB, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.db != nil {
return s.db, nil
}
db, err := sql.Open("sqlite3", "file:"+s.dbPath+"?mode=ro&_busy_timeout=3000")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(2)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
s.db = db
return db, nil
}
// Mount registers the stats-service routes on mux.
func (s *StatsService) Mount(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/counters", s.handleCounters)
mux.HandleFunc("GET /v1/lines", s.handleLines)
mux.HandleFunc("GET /v1/lines/{id}", s.handleLine)
mux.HandleFunc("GET /v1/pages/main", s.handlePagesMain)
mux.HandleFunc("GET /v1/pages/transactions", s.handlePagesTransactions)
mux.HandleFunc("GET /v1/pages/contracts", s.handlePagesContracts)
}
// ---- Wire shapes (match @luxfi/stats-types) ----
type statsCounter struct {
ID string `json:"id"`
Value string `json:"value"`
Title string `json:"title"`
Units string `json:"units,omitempty"`
Description string `json:"description"`
}
type statsPoint struct {
Date string `json:"date"`
DateTo string `json:"date_to"`
Value string `json:"value"`
IsApproximate bool `json:"is_approximate"`
}
type statsLineInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Units string `json:"units,omitempty"`
Resolutions []string `json:"resolutions"`
}
type statsLineChart struct {
Chart []statsPoint `json:"chart"`
Info statsLineInfo `json:"info"`
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// ---- Aggregate helpers ----
func (s *StatsService) count(db *sql.DB, table string) int64 {
var n int64
// #nosec G201 — table is a fixed internal identifier, never user input.
db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n)
return n
}
// avgBlockTimeSec returns the lifetime average block interval in seconds,
// derived from the full timestamp span so it stays meaningful on a sparse,
// demand-driven chain (per-window deltas collapse to zero when idle).
func (s *StatsService) avgBlockTimeSec(db *sql.DB) float64 {
var count, minTS, maxTS int64
db.QueryRow("SELECT COUNT(*), COALESCE(MIN(timestamp),0), COALESCE(MAX(timestamp),0) FROM evm_blocks").
Scan(&count, &minTS, &maxTS)
if count < 2 || maxTS <= minTS {
return 0
}
return float64(maxTS-minTS) / float64(count-1)
}
// dailySeries buckets a COUNT by UTC day over the trailing `days` window.
// timestamp is a unix-epoch integer, so strftime needs the 'unixepoch' modifier.
func (s *StatsService) dailySeries(db *sql.DB, query string, days int) ([]statsPoint, error) {
since := time.Now().AddDate(0, 0, -days).Unix()
rows, err := db.Query(query, since)
if err != nil {
return nil, err
}
defer rows.Close()
points := make([]statsPoint, 0, days)
for rows.Next() {
var day string
var value int64
if err := rows.Scan(&day, &value); err != nil {
return nil, err
}
points = append(points, statsPoint{Date: day, DateTo: day, Value: fmt.Sprintf("%d", value)})
}
return points, rows.Err()
}
// ---- Handlers ----
func (s *StatsService) handleCounters(w http.ResponseWriter, r *http.Request) {
db, err := s.conn()
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"counters": []statsCounter{}})
return
}
blocks := s.count(db, "evm_blocks")
txns := s.count(db, "evm_transactions")
addrs := s.count(db, "evm_addresses")
avgSec := s.avgBlockTimeSec(db)
counters := []statsCounter{
{ID: "total_blocks", Value: fmt.Sprintf("%d", blocks), Title: "Total blocks", Description: "All blocks indexed on the chain"},
{ID: "total_txns", Value: fmt.Sprintf("%d", txns), Title: "Total transactions", Description: "All transactions indexed on the chain"},
{ID: "completed_txns", Value: fmt.Sprintf("%d", txns), Title: "Completed transactions", Description: "Transactions included in a block"},
{ID: "total_addresses", Value: fmt.Sprintf("%d", addrs), Title: "Total addresses", Description: "Distinct addresses seen on the chain"},
{ID: "average_block_time", Value: fmt.Sprintf("%.1f", avgSec), Title: "Average block time", Units: "s", Description: "Average interval between blocks"},
}
writeJSON(w, http.StatusOK, map[string]any{"counters": counters})
}
// lineInfos is the catalogue of charts this backend can serve, each backed by a
// real SQLite aggregation.
var lineInfos = map[string]statsLineInfo{
"newBlocksPerDay": {
ID: "newBlocksPerDay", Title: "New blocks per day",
Description: "Number of blocks produced each day", Resolutions: []string{"DAY"},
},
"newTxnsPerDay": {
ID: "newTxnsPerDay", Title: "New transactions per day",
Description: "Number of transactions each day", Resolutions: []string{"DAY"},
},
}
func (s *StatsService) handleLines(w http.ResponseWriter, r *http.Request) {
sections := []map[string]any{
{
"id": "blocks", "title": "Blocks",
"charts": []statsLineInfo{lineInfos["newBlocksPerDay"]},
},
{
"id": "transactions", "title": "Transactions",
"charts": []statsLineInfo{lineInfos["newTxnsPerDay"]},
},
}
writeJSON(w, http.StatusOK, map[string]any{"sections": sections})
}
func (s *StatsService) handleLine(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
info, ok := lineInfos[id]
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "unknown chart"})
return
}
db, err := s.conn()
if err != nil {
writeJSON(w, http.StatusOK, statsLineChart{Chart: []statsPoint{}, Info: info})
return
}
var query string
switch id {
case "newBlocksPerDay":
query = `SELECT strftime('%Y-%m-%d', timestamp, 'unixepoch') d, COUNT(*)
FROM evm_blocks WHERE timestamp > ? GROUP BY d ORDER BY d`
case "newTxnsPerDay":
query = `SELECT strftime('%Y-%m-%d', b.timestamp, 'unixepoch') d, COUNT(*)
FROM evm_transactions t JOIN evm_blocks b ON t.block_number = b.number
WHERE b.timestamp > ? GROUP BY d ORDER BY d`
}
points, err := s.dailySeries(db, query, 365)
if err != nil {
writeJSON(w, http.StatusOK, statsLineChart{Chart: []statsPoint{}, Info: info})
return
}
writeJSON(w, http.StatusOK, statsLineChart{Chart: points, Info: info})
}
func (s *StatsService) handlePagesMain(w http.ResponseWriter, r *http.Request) {
db, err := s.conn()
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{})
return
}
blocks := s.count(db, "evm_blocks")
txns := s.count(db, "evm_transactions")
addrs := s.count(db, "evm_addresses")
avgSec := s.avgBlockTimeSec(db)
daily, _ := s.dailySeries(db, `SELECT strftime('%Y-%m-%d', b.timestamp, 'unixepoch') d, COUNT(*)
FROM evm_transactions t JOIN evm_blocks b ON t.block_number = b.number
WHERE b.timestamp > ? GROUP BY d ORDER BY d`, 30)
writeJSON(w, http.StatusOK, map[string]any{
"total_blocks": statsCounter{ID: "total_blocks", Value: fmt.Sprintf("%d", blocks), Title: "Total blocks", Description: "All blocks"},
"total_transactions": statsCounter{ID: "total_txns", Value: fmt.Sprintf("%d", txns), Title: "Total transactions", Description: "All transactions"},
"total_addresses": statsCounter{ID: "total_addresses", Value: fmt.Sprintf("%d", addrs), Title: "Total addresses", Description: "Distinct addresses"},
"average_block_time": statsCounter{ID: "average_block_time", Value: fmt.Sprintf("%.1f", avgSec), Title: "Average block time", Units: "s", Description: "Average interval between blocks"},
"daily_new_transactions": statsLineChart{Chart: daily, Info: lineInfos["newTxnsPerDay"]},
})
}
func (s *StatsService) handlePagesTransactions(w http.ResponseWriter, r *http.Request) {
db, err := s.conn()
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{})
return
}
var txns24h int64
db.QueryRow(`SELECT COUNT(*) FROM evm_transactions t JOIN evm_blocks b ON t.block_number = b.number
WHERE b.timestamp > ?`, time.Now().AddDate(0, 0, -1).Unix()).Scan(&txns24h)
writeJSON(w, http.StatusOK, map[string]any{
"transactions_24h": statsCounter{ID: "new_txns_24h", Value: fmt.Sprintf("%d", txns24h), Title: "Transactions (24h)", Description: "Transactions in the last 24 hours"},
})
}
func (s *StatsService) handlePagesContracts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{})
}