Freshness diagnostics API, UI trust notes, mission control/stats updates, and deploy scripts.
Made-with: Cursor
This commit is contained in:
398
backend/api/freshness/freshness.go
Normal file
398
backend/api/freshness/freshness.go
Normal file
@@ -0,0 +1,398 @@
|
||||
package freshness
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type QueryRowFunc func(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
|
||||
type Confidence string
|
||||
|
||||
const (
|
||||
ConfidenceHigh Confidence = "high"
|
||||
ConfidenceMedium Confidence = "medium"
|
||||
ConfidenceLow Confidence = "low"
|
||||
ConfidenceUnknown Confidence = "unknown"
|
||||
)
|
||||
|
||||
type Completeness string
|
||||
|
||||
const (
|
||||
CompletenessComplete Completeness = "complete"
|
||||
CompletenessPartial Completeness = "partial"
|
||||
CompletenessStale Completeness = "stale"
|
||||
CompletenessUnavailable Completeness = "unavailable"
|
||||
)
|
||||
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceReported Source = "reported"
|
||||
SourceDerived Source = "derived"
|
||||
SourceSampled Source = "sampled"
|
||||
SourceUnavailable Source = "unavailable"
|
||||
)
|
||||
|
||||
type Provenance string
|
||||
|
||||
const (
|
||||
ProvenanceRPC Provenance = "rpc"
|
||||
ProvenanceExplorerIndex Provenance = "explorer_index"
|
||||
ProvenanceTxIndex Provenance = "tx_index"
|
||||
ProvenanceMissionFeed Provenance = "mission_control_feed"
|
||||
ProvenanceComposite Provenance = "composite"
|
||||
)
|
||||
|
||||
type Reference struct {
|
||||
BlockNumber *int64 `json:"block_number"`
|
||||
Timestamp *string `json:"timestamp"`
|
||||
AgeSeconds *int64 `json:"age_seconds"`
|
||||
Hash *string `json:"hash,omitempty"`
|
||||
DistanceFromHead *int64 `json:"distance_from_head,omitempty"`
|
||||
Source Source `json:"source"`
|
||||
Confidence Confidence `json:"confidence"`
|
||||
Provenance Provenance `json:"provenance"`
|
||||
Completeness Completeness `json:"completeness,omitempty"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ChainHead Reference `json:"chain_head"`
|
||||
LatestIndexedBlock Reference `json:"latest_indexed_block"`
|
||||
LatestIndexedTransaction Reference `json:"latest_indexed_transaction"`
|
||||
LatestNonEmptyBlock Reference `json:"latest_non_empty_block"`
|
||||
}
|
||||
|
||||
type SummaryCompleteness struct {
|
||||
TransactionsFeed Completeness `json:"transactions_feed"`
|
||||
BlocksFeed Completeness `json:"blocks_feed"`
|
||||
GasMetrics Completeness `json:"gas_metrics"`
|
||||
UtilizationMetric Completeness `json:"utilization_metrics"`
|
||||
}
|
||||
|
||||
type Sampling struct {
|
||||
StatsGeneratedAt *string `json:"stats_generated_at"`
|
||||
RPCProbeAt *string `json:"rpc_probe_at"`
|
||||
StatsWindowSec *int64 `json:"stats_window_seconds,omitempty"`
|
||||
Issues map[string]string `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
type HeadProbeFunc func(ctx context.Context) (*Reference, error)
|
||||
|
||||
func ptrInt64(value int64) *int64 { return &value }
|
||||
|
||||
func ptrString(value string) *string { return &value }
|
||||
|
||||
func unknownReference(provenance Provenance) Reference {
|
||||
return Reference{
|
||||
Source: SourceUnavailable,
|
||||
Confidence: ConfidenceUnknown,
|
||||
Provenance: provenance,
|
||||
Completeness: CompletenessUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *string {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
formatted := value.UTC().Format(time.RFC3339)
|
||||
return &formatted
|
||||
}
|
||||
|
||||
func computeAge(timestamp *string, now time.Time) *int64 {
|
||||
if timestamp == nil || *timestamp == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, *timestamp)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
age := int64(now.Sub(parsed).Seconds())
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return &age
|
||||
}
|
||||
|
||||
func classifyIndexedVisibility(age *int64) Completeness {
|
||||
if age == nil {
|
||||
return CompletenessUnavailable
|
||||
}
|
||||
switch {
|
||||
case *age <= 15*60:
|
||||
return CompletenessComplete
|
||||
case *age <= 3*60*60:
|
||||
return CompletenessPartial
|
||||
default:
|
||||
return CompletenessStale
|
||||
}
|
||||
}
|
||||
|
||||
func classifyBlockFeed(chainHead *int64, indexedHead *int64) Completeness {
|
||||
if chainHead == nil || indexedHead == nil {
|
||||
return CompletenessUnavailable
|
||||
}
|
||||
distance := *chainHead - *indexedHead
|
||||
if distance < 0 {
|
||||
distance = 0
|
||||
}
|
||||
switch {
|
||||
case distance <= 2:
|
||||
return CompletenessComplete
|
||||
case distance <= 32:
|
||||
return CompletenessPartial
|
||||
default:
|
||||
return CompletenessStale
|
||||
}
|
||||
}
|
||||
|
||||
func classifyMetricPresence[T comparable](value *T) Completeness {
|
||||
if value == nil {
|
||||
return CompletenessUnavailable
|
||||
}
|
||||
return CompletenessComplete
|
||||
}
|
||||
|
||||
func BuildSnapshot(
|
||||
ctx context.Context,
|
||||
chainID int,
|
||||
queryRow QueryRowFunc,
|
||||
probeHead HeadProbeFunc,
|
||||
now time.Time,
|
||||
averageGasPrice *float64,
|
||||
utilization *float64,
|
||||
) (Snapshot, SummaryCompleteness, Sampling, error) {
|
||||
snapshot := Snapshot{
|
||||
ChainHead: unknownReference(ProvenanceRPC),
|
||||
LatestIndexedBlock: unknownReference(ProvenanceExplorerIndex),
|
||||
LatestIndexedTransaction: unknownReference(ProvenanceTxIndex),
|
||||
LatestNonEmptyBlock: unknownReference(ProvenanceTxIndex),
|
||||
}
|
||||
issues := map[string]string{}
|
||||
|
||||
if probeHead != nil {
|
||||
if head, err := probeHead(ctx); err == nil && head != nil {
|
||||
snapshot.ChainHead = *head
|
||||
} else if err != nil {
|
||||
issues["chain_head"] = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
var latestIndexedBlockNumber int64
|
||||
var latestIndexedBlockTime time.Time
|
||||
if err := queryRow(ctx,
|
||||
`SELECT number, timestamp
|
||||
FROM blocks
|
||||
ORDER BY number DESC
|
||||
LIMIT 1`,
|
||||
).Scan(&latestIndexedBlockNumber, &latestIndexedBlockTime); err == nil {
|
||||
timestamp := timePointer(latestIndexedBlockTime)
|
||||
snapshot.LatestIndexedBlock = Reference{
|
||||
BlockNumber: ptrInt64(latestIndexedBlockNumber),
|
||||
Timestamp: timestamp,
|
||||
AgeSeconds: computeAge(timestamp, now),
|
||||
Source: SourceReported,
|
||||
Confidence: ConfidenceHigh,
|
||||
Provenance: ProvenanceExplorerIndex,
|
||||
Completeness: CompletenessComplete,
|
||||
}
|
||||
} else {
|
||||
issues["latest_indexed_block"] = err.Error()
|
||||
}
|
||||
|
||||
var latestTxHash string
|
||||
var latestTxBlock int64
|
||||
var latestTxCreatedAt time.Time
|
||||
if err := queryRow(ctx,
|
||||
`SELECT concat('0x', encode(hash, 'hex')), block_number::bigint, COALESCE(block_timestamp, inserted_at)
|
||||
FROM transactions
|
||||
WHERE block_number IS NOT NULL
|
||||
ORDER BY block_number DESC, "index" DESC
|
||||
LIMIT 1`,
|
||||
).Scan(&latestTxHash, &latestTxBlock, &latestTxCreatedAt); err == nil {
|
||||
timestamp := timePointer(latestTxCreatedAt)
|
||||
snapshot.LatestIndexedTransaction = Reference{
|
||||
BlockNumber: ptrInt64(latestTxBlock),
|
||||
Timestamp: timestamp,
|
||||
AgeSeconds: computeAge(timestamp, now),
|
||||
Hash: ptrString(latestTxHash),
|
||||
Source: SourceReported,
|
||||
Confidence: ConfidenceHigh,
|
||||
Provenance: ProvenanceTxIndex,
|
||||
Completeness: classifyIndexedVisibility(computeAge(timestamp, now)),
|
||||
}
|
||||
} else {
|
||||
issues["latest_indexed_transaction"] = err.Error()
|
||||
}
|
||||
|
||||
var latestNonEmptyBlockNumber int64
|
||||
var latestNonEmptyBlockTime time.Time
|
||||
if err := queryRow(ctx,
|
||||
`SELECT b.number, b.timestamp
|
||||
FROM blocks b
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM transactions t
|
||||
WHERE t.block_number = b.number
|
||||
)
|
||||
ORDER BY b.number DESC
|
||||
LIMIT 1`,
|
||||
).Scan(&latestNonEmptyBlockNumber, &latestNonEmptyBlockTime); err == nil {
|
||||
timestamp := timePointer(latestNonEmptyBlockTime)
|
||||
ref := Reference{
|
||||
BlockNumber: ptrInt64(latestNonEmptyBlockNumber),
|
||||
Timestamp: timestamp,
|
||||
AgeSeconds: computeAge(timestamp, now),
|
||||
Source: SourceReported,
|
||||
Confidence: ConfidenceHigh,
|
||||
Provenance: ProvenanceTxIndex,
|
||||
Completeness: classifyIndexedVisibility(computeAge(timestamp, now)),
|
||||
}
|
||||
if snapshot.ChainHead.BlockNumber != nil {
|
||||
distance := *snapshot.ChainHead.BlockNumber - latestNonEmptyBlockNumber
|
||||
if distance < 0 {
|
||||
distance = 0
|
||||
}
|
||||
ref.DistanceFromHead = ptrInt64(distance)
|
||||
}
|
||||
snapshot.LatestNonEmptyBlock = ref
|
||||
} else {
|
||||
issues["latest_non_empty_block"] = err.Error()
|
||||
}
|
||||
|
||||
statsGeneratedAt := now.UTC().Format(time.RFC3339)
|
||||
sampling := Sampling{
|
||||
StatsGeneratedAt: ptrString(statsGeneratedAt),
|
||||
StatsWindowSec: ptrInt64(300),
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
sampling.Issues = issues
|
||||
}
|
||||
if snapshot.ChainHead.Timestamp != nil {
|
||||
sampling.RPCProbeAt = snapshot.ChainHead.Timestamp
|
||||
}
|
||||
|
||||
completeness := SummaryCompleteness{
|
||||
TransactionsFeed: snapshot.LatestIndexedTransaction.Completeness,
|
||||
BlocksFeed: classifyBlockFeed(snapshot.ChainHead.BlockNumber, snapshot.LatestIndexedBlock.BlockNumber),
|
||||
GasMetrics: classifyMetricPresence(averageGasPrice),
|
||||
UtilizationMetric: classifyMetricPresence(utilization),
|
||||
}
|
||||
|
||||
return snapshot, completeness, sampling, nil
|
||||
}
|
||||
|
||||
func ProbeChainHead(ctx context.Context, rpcURL string) (*Reference, error) {
|
||||
rpcURL = strings.TrimSpace(rpcURL)
|
||||
if rpcURL == "" {
|
||||
return nil, fmt.Errorf("empty rpc url")
|
||||
}
|
||||
|
||||
blockNumberRaw, _, err := postJSONRPC(ctx, rpcURL, "eth_blockNumber", []interface{}{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var blockNumberHex string
|
||||
if err := json.Unmarshal(blockNumberRaw, &blockNumberHex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blockNumber, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(blockNumberHex), "0x"), 16, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blockRaw, _, err := postJSONRPC(ctx, rpcURL, "eth_getBlockByNumber", []interface{}{"latest", false})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var latestBlock struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
if err := json.Unmarshal(blockRaw, &latestBlock); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blockTimeHex := strings.TrimSpace(latestBlock.Timestamp)
|
||||
if blockTimeHex == "" {
|
||||
return nil, fmt.Errorf("missing block timestamp")
|
||||
}
|
||||
blockTimestamp, err := strconv.ParseInt(strings.TrimPrefix(blockTimeHex, "0x"), 16, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts := time.Unix(blockTimestamp, 0).UTC()
|
||||
timestamp := ts.Format(time.RFC3339)
|
||||
now := time.Now().UTC()
|
||||
age := int64(now.Sub(ts).Seconds())
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
|
||||
return &Reference{
|
||||
BlockNumber: ptrInt64(blockNumber),
|
||||
Timestamp: ptrString(timestamp),
|
||||
AgeSeconds: ptrInt64(age),
|
||||
Source: SourceReported,
|
||||
Confidence: ConfidenceHigh,
|
||||
Provenance: ProvenanceRPC,
|
||||
Completeness: CompletenessComplete,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func postJSONRPC(ctx context.Context, rpcURL string, method string, params []interface{}) (json.RawMessage, int64, error) {
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 6 * time.Second}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return nil, latency, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, latency, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, latency, fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
return nil, latency, err
|
||||
}
|
||||
if out.Error != nil && out.Error.Message != "" {
|
||||
return nil, latency, fmt.Errorf("rpc error: %s", out.Error.Message)
|
||||
}
|
||||
return out.Result, latency, nil
|
||||
}
|
||||
192
backend/api/freshness/freshness_test.go
Normal file
192
backend/api/freshness/freshness_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package freshness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeRow struct {
|
||||
scan func(dest ...any) error
|
||||
}
|
||||
|
||||
func (r fakeRow) Scan(dest ...any) error {
|
||||
return r.scan(dest...)
|
||||
}
|
||||
|
||||
func TestBuildSnapshotHealthyState(t *testing.T) {
|
||||
now := time.Date(2026, 4, 10, 22, 10, 16, 0, time.UTC)
|
||||
call := 0
|
||||
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
|
||||
call++
|
||||
switch call {
|
||||
case 1:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*int64) = 200
|
||||
*dest[1].(*time.Time) = now.Add(-2 * time.Second)
|
||||
return nil
|
||||
}}
|
||||
case 2:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*string) = "0xabc"
|
||||
*dest[1].(*int64) = 198
|
||||
*dest[2].(*time.Time) = now.Add(-5 * time.Second)
|
||||
return nil
|
||||
}}
|
||||
case 3:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*int64) = 198
|
||||
*dest[1].(*time.Time) = now.Add(-5 * time.Second)
|
||||
return nil
|
||||
}}
|
||||
default:
|
||||
t.Fatalf("unexpected call %d", call)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
probe := func(context.Context) (*Reference, error) {
|
||||
ts := now.Add(-1 * time.Second).Format(time.RFC3339)
|
||||
age := int64(1)
|
||||
block := int64(200)
|
||||
return &Reference{
|
||||
BlockNumber: &block,
|
||||
Timestamp: &ts,
|
||||
AgeSeconds: &age,
|
||||
Source: SourceReported,
|
||||
Confidence: ConfidenceHigh,
|
||||
Provenance: ProvenanceRPC,
|
||||
Completeness: CompletenessComplete,
|
||||
}, nil
|
||||
}
|
||||
|
||||
snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(200), *snapshot.ChainHead.BlockNumber)
|
||||
require.Equal(t, int64(198), *snapshot.LatestIndexedTransaction.BlockNumber)
|
||||
require.Equal(t, int64(2), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
||||
require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
|
||||
require.NotNil(t, sampling.StatsGeneratedAt)
|
||||
}
|
||||
|
||||
func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
|
||||
now := time.Date(2026, 4, 11, 0, 10, 16, 0, time.UTC)
|
||||
call := 0
|
||||
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
|
||||
call++
|
||||
switch call {
|
||||
case 1:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*int64) = 3875999
|
||||
*dest[1].(*time.Time) = now.Add(-3 * time.Second)
|
||||
return nil
|
||||
}}
|
||||
case 2:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*string) = "0xstale"
|
||||
*dest[1].(*int64) = 3860660
|
||||
*dest[2].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
|
||||
return nil
|
||||
}}
|
||||
case 3:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*int64) = 3860660
|
||||
*dest[1].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
|
||||
return nil
|
||||
}}
|
||||
default:
|
||||
t.Fatalf("unexpected call %d", call)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
probe := func(context.Context) (*Reference, error) {
|
||||
ts := now.Add(-1 * time.Second).Format(time.RFC3339)
|
||||
age := int64(1)
|
||||
block := int64(3876000)
|
||||
return &Reference{
|
||||
BlockNumber: &block,
|
||||
Timestamp: &ts,
|
||||
AgeSeconds: &age,
|
||||
Source: SourceReported,
|
||||
Confidence: ConfidenceHigh,
|
||||
Provenance: ProvenanceRPC,
|
||||
Completeness: CompletenessComplete,
|
||||
}, nil
|
||||
}
|
||||
|
||||
snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(15340), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
||||
require.Equal(t, CompletenessStale, completeness.TransactionsFeed)
|
||||
require.Equal(t, CompletenessComplete, completeness.BlocksFeed)
|
||||
}
|
||||
|
||||
func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
|
||||
now := time.Date(2026, 4, 10, 23, 10, 16, 0, time.UTC)
|
||||
call := 0
|
||||
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
|
||||
call++
|
||||
switch call {
|
||||
case 1:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*int64) = 3875000
|
||||
*dest[1].(*time.Time) = now.Add(-1 * time.Second)
|
||||
return nil
|
||||
}}
|
||||
case 2:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*string) = "0xquiet"
|
||||
*dest[1].(*int64) = 3874902
|
||||
*dest[2].(*time.Time) = now.Add(-512 * time.Second)
|
||||
return nil
|
||||
}}
|
||||
case 3:
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
*dest[0].(*int64) = 3874902
|
||||
*dest[1].(*time.Time) = now.Add(-512 * time.Second)
|
||||
return nil
|
||||
}}
|
||||
default:
|
||||
t.Fatalf("unexpected call %d", call)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
probe := func(context.Context) (*Reference, error) {
|
||||
ts := now.Add(-1 * time.Second).Format(time.RFC3339)
|
||||
age := int64(1)
|
||||
block := int64(3875000)
|
||||
return &Reference{
|
||||
BlockNumber: &block,
|
||||
Timestamp: &ts,
|
||||
AgeSeconds: &age,
|
||||
Source: SourceReported,
|
||||
Confidence: ConfidenceHigh,
|
||||
Provenance: ProvenanceRPC,
|
||||
Completeness: CompletenessComplete,
|
||||
}, nil
|
||||
}
|
||||
|
||||
snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(98), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
||||
require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
|
||||
}
|
||||
|
||||
func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) {
|
||||
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
|
||||
return fakeRow{scan: func(dest ...any) error {
|
||||
return pgx.ErrNoRows
|
||||
}}
|
||||
}
|
||||
|
||||
snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, nil, time.Now().UTC(), nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, snapshot.ChainHead.BlockNumber)
|
||||
require.Equal(t, CompletenessUnavailable, completeness.TransactionsFeed)
|
||||
require.NotNil(t, sampling.StatsGeneratedAt)
|
||||
}
|
||||
@@ -48,6 +48,46 @@ func tokenAggregationBase() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeGenericUpstreamErrorPayload(body []byte) bool {
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errValue, ok := payload["error"].(string)
|
||||
if !ok || strings.TrimSpace(errValue) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := payload["pools"]; ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := payload["tokens"]; ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := payload["data"]; ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := payload["chains"]; ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := payload["tree"]; ok {
|
||||
return false
|
||||
}
|
||||
if _, ok := payload["quote"]; ok {
|
||||
return false
|
||||
}
|
||||
if status, ok := payload["status"].(string); ok && strings.EqualFold(status, "healthy") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func blockscoutInternalBase() string {
|
||||
u := strings.TrimSpace(os.Getenv("BLOCKSCOUT_INTERNAL_URL"))
|
||||
if u == "" {
|
||||
@@ -156,6 +196,15 @@ func (s *Server) handleMissionControlLiquidityTokenPath(w http.ResponseWriter, r
|
||||
if ctype == "" {
|
||||
ctype = "application/json"
|
||||
}
|
||||
isGenericSuccessError := resp.StatusCode >= 200 && resp.StatusCode < 300 && looksLikeGenericUpstreamErrorPayload(body)
|
||||
if isGenericSuccessError {
|
||||
atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
|
||||
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss upstream_status=%d generic_error_envelope=true", strings.ToLower(addr), chain, resp.StatusCode)
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
liquidityPoolsCache.Store(cacheKey, liquidityCacheEntry{
|
||||
body: body,
|
||||
|
||||
@@ -98,6 +98,37 @@ func TestHandleMissionControlLiquidityTokenPathBypassesCacheWhenRequested(t *tes
|
||||
require.Equal(t, 2, hitCount, "refresh=1 should force a fresh upstream read")
|
||||
}
|
||||
|
||||
func TestHandleMissionControlLiquidityTokenPathTreatsGenericSuccessErrorEnvelopeAsBadGateway(t *testing.T) {
|
||||
resetMissionControlTestGlobals()
|
||||
|
||||
var hitCount int
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hitCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"error":"Internal server error"}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
t.Setenv("TOKEN_AGGREGATION_BASE_URL", upstream.URL)
|
||||
t.Setenv("CHAIN_ID", "138")
|
||||
|
||||
s := NewServer(nil, 138)
|
||||
path := "/api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools"
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
s.handleMissionControlLiquidityTokenPath(w1, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusBadGateway, w1.Code)
|
||||
require.Equal(t, "miss", w1.Header().Get("X-Mission-Control-Cache"))
|
||||
require.JSONEq(t, `{"error":"Internal server error"}`, w1.Body.String())
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
s.handleMissionControlLiquidityTokenPath(w2, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusBadGateway, w2.Code)
|
||||
require.Equal(t, "miss", w2.Header().Get("X-Mission-Control-Cache"))
|
||||
require.Equal(t, 2, hitCount, "generic error envelopes must not be cached as success")
|
||||
}
|
||||
|
||||
func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) {
|
||||
resetMissionControlTestGlobals()
|
||||
|
||||
|
||||
@@ -2,62 +2,190 @@ package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"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"`
|
||||
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 statsQueryFunc func(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
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 WHERE chain_id = $1`,
|
||||
chainID,
|
||||
`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 chain_id = $1`,
|
||||
chainID,
|
||||
`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 AS address
|
||||
SELECT from_address_hash AS address
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
|
||||
WHERE from_address_hash IS NOT NULL
|
||||
UNION
|
||||
SELECT to_address AS address
|
||||
SELECT to_address_hash AS address
|
||||
FROM transactions
|
||||
WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
|
||||
WHERE to_address_hash IS NOT NULL
|
||||
) unique_addresses`,
|
||||
chainID,
|
||||
).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 WHERE chain_id = $1`,
|
||||
chainID,
|
||||
`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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,17 @@ package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -19,23 +26,56 @@ func (r fakeStatsRow) Scan(dest ...any) error {
|
||||
}
|
||||
|
||||
func TestLoadExplorerStatsReturnsValues(t *testing.T) {
|
||||
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "eth_blockNumber":
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x2c"}`))
|
||||
case "eth_getBlockByNumber":
|
||||
ts := time.Now().Add(-2 * time.Second).Unix()
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`))
|
||||
default:
|
||||
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer rpc.Close()
|
||||
t.Setenv("RPC_URL", rpc.URL)
|
||||
var call int
|
||||
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
|
||||
call++
|
||||
return fakeStatsRow{
|
||||
scan: func(dest ...any) error {
|
||||
target, ok := dest[0].(*int64)
|
||||
require.True(t, ok)
|
||||
|
||||
switch call {
|
||||
case 1:
|
||||
*target = 11
|
||||
*dest[0].(*int64) = 11
|
||||
case 2:
|
||||
*target = 22
|
||||
*dest[0].(*int64) = 22
|
||||
case 3:
|
||||
*target = 33
|
||||
*dest[0].(*int64) = 33
|
||||
case 4:
|
||||
*target = 44
|
||||
*dest[0].(*int64) = 44
|
||||
case 5:
|
||||
*dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 2000, Valid: true}
|
||||
case 6:
|
||||
*dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 1.25, Valid: true}
|
||||
case 7:
|
||||
*dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 37.5, Valid: true}
|
||||
case 8:
|
||||
*dest[0].(*sql.NullInt64) = sql.NullInt64{Int64: 18, Valid: true}
|
||||
case 9:
|
||||
*dest[0].(*int64) = 44
|
||||
*dest[1].(*time.Time) = time.Now().Add(-2 * time.Second)
|
||||
case 10:
|
||||
*dest[0].(*string) = "0xabc"
|
||||
*dest[1].(*int64) = 40
|
||||
*dest[2].(*time.Time) = time.Now().Add(-5 * time.Second)
|
||||
case 11:
|
||||
*dest[0].(*int64) = 40
|
||||
*dest[1].(*time.Time) = time.Now().Add(-5 * time.Second)
|
||||
default:
|
||||
t.Fatalf("unexpected query call %d", call)
|
||||
}
|
||||
@@ -50,9 +90,25 @@ func TestLoadExplorerStatsReturnsValues(t *testing.T) {
|
||||
require.Equal(t, int64(22), stats.TotalTransactions)
|
||||
require.Equal(t, int64(33), stats.TotalAddresses)
|
||||
require.Equal(t, int64(44), stats.LatestBlock)
|
||||
require.NotNil(t, stats.AverageBlockTime)
|
||||
require.Equal(t, 2000.0, *stats.AverageBlockTime)
|
||||
require.NotNil(t, stats.GasPrices)
|
||||
require.NotNil(t, stats.GasPrices.Average)
|
||||
require.Equal(t, 1.25, *stats.GasPrices.Average)
|
||||
require.NotNil(t, stats.NetworkUtilizationPercentage)
|
||||
require.Equal(t, 37.5, *stats.NetworkUtilizationPercentage)
|
||||
require.NotNil(t, stats.TransactionsToday)
|
||||
require.Equal(t, int64(18), *stats.TransactionsToday)
|
||||
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
|
||||
require.Equal(t, int64(40), *stats.Freshness.LatestIndexedTransaction.BlockNumber)
|
||||
require.Equal(t, int64(4), *stats.Freshness.LatestNonEmptyBlock.DistanceFromHead)
|
||||
require.Equal(t, "reported", string(stats.Freshness.ChainHead.Source))
|
||||
require.Equal(t, freshness.CompletenessComplete, stats.Completeness.GasMetrics)
|
||||
require.Equal(t, freshness.CompletenessComplete, stats.Completeness.UtilizationMetric)
|
||||
}
|
||||
|
||||
func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
|
||||
t.Setenv("RPC_URL", "")
|
||||
queryRow := func(_ context.Context, query string, _ ...any) pgx.Row {
|
||||
return fakeStatsRow{
|
||||
scan: func(dest ...any) error {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
"github.com/explorer/backend/api/middleware"
|
||||
"github.com/explorer/backend/api/track1"
|
||||
"github.com/explorer/backend/api/track2"
|
||||
@@ -47,7 +50,27 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
|
||||
}
|
||||
|
||||
rpcGateway := gateway.NewRPCGateway(rpcURL, cache, rateLimiter)
|
||||
track1Server := track1.NewServer(rpcGateway)
|
||||
track1Server := track1.NewServer(rpcGateway, func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
|
||||
if s.db == nil {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
snapshot, completeness, sampling, err := freshness.BuildSnapshot(
|
||||
ctx,
|
||||
s.chainID,
|
||||
s.db.QueryRow,
|
||||
func(ctx context.Context) (*freshness.Reference, error) {
|
||||
return freshness.ProbeChainHead(ctx, rpcURL)
|
||||
},
|
||||
now,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return &snapshot, &completeness, &sampling, nil
|
||||
})
|
||||
|
||||
// Track 1 routes (public, optional auth)
|
||||
mux.HandleFunc("/api/v1/track1/blocks/latest", track1Server.HandleLatestBlocks)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
)
|
||||
|
||||
func relaySnapshotStatus(relay map[string]interface{}) string {
|
||||
@@ -129,6 +131,81 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
||||
if ov := readOptionalVerifyJSON(); ov != nil {
|
||||
data["operator_verify"] = ov
|
||||
}
|
||||
if s.freshnessLoader != nil {
|
||||
if snapshot, completeness, sampling, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
|
||||
subsystems := map[string]interface{}{
|
||||
"rpc_head": map[string]interface{}{
|
||||
"status": chainStatusFromProbe(p138),
|
||||
"updated_at": valueOrNil(snapshot.ChainHead.Timestamp),
|
||||
"age_seconds": valueOrNil(snapshot.ChainHead.AgeSeconds),
|
||||
"source": snapshot.ChainHead.Source,
|
||||
"confidence": snapshot.ChainHead.Confidence,
|
||||
"provenance": snapshot.ChainHead.Provenance,
|
||||
"completeness": snapshot.ChainHead.Completeness,
|
||||
},
|
||||
"tx_index": map[string]interface{}{
|
||||
"status": completenessStatus(completeness.TransactionsFeed),
|
||||
"updated_at": valueOrNil(snapshot.LatestIndexedTransaction.Timestamp),
|
||||
"age_seconds": valueOrNil(snapshot.LatestIndexedTransaction.AgeSeconds),
|
||||
"source": snapshot.LatestIndexedTransaction.Source,
|
||||
"confidence": snapshot.LatestIndexedTransaction.Confidence,
|
||||
"provenance": snapshot.LatestIndexedTransaction.Provenance,
|
||||
"completeness": completeness.TransactionsFeed,
|
||||
},
|
||||
"stats_summary": map[string]interface{}{
|
||||
"status": completenessStatus(completeness.BlocksFeed),
|
||||
"updated_at": valueOrNil(sampling.StatsGeneratedAt),
|
||||
"age_seconds": ageSinceRFC3339(sampling.StatsGeneratedAt),
|
||||
"source": freshness.SourceReported,
|
||||
"confidence": freshness.ConfidenceMedium,
|
||||
"provenance": freshness.ProvenanceComposite,
|
||||
"completeness": completeness.BlocksFeed,
|
||||
},
|
||||
}
|
||||
if len(sampling.Issues) > 0 {
|
||||
subsystems["freshness_queries"] = map[string]interface{}{
|
||||
"status": "degraded",
|
||||
"updated_at": valueOrNil(sampling.StatsGeneratedAt),
|
||||
"age_seconds": ageSinceRFC3339(sampling.StatsGeneratedAt),
|
||||
"source": freshness.SourceDerived,
|
||||
"confidence": freshness.ConfidenceMedium,
|
||||
"provenance": freshness.ProvenanceComposite,
|
||||
"completeness": freshness.CompletenessPartial,
|
||||
"issues": sampling.Issues,
|
||||
}
|
||||
}
|
||||
modeKind := "live"
|
||||
modeReason := any(nil)
|
||||
modeScope := any(nil)
|
||||
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
||||
modeKind = "snapshot"
|
||||
modeReason = "live_homepage_stream_not_attached"
|
||||
modeScope = "relay_monitoring_homepage_card_only"
|
||||
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
|
||||
"status": overall,
|
||||
"updated_at": now,
|
||||
"age_seconds": int64(0),
|
||||
"source": freshness.SourceReported,
|
||||
"confidence": freshness.ConfidenceHigh,
|
||||
"provenance": freshness.ProvenanceMissionFeed,
|
||||
"completeness": freshness.CompletenessComplete,
|
||||
}
|
||||
}
|
||||
data["freshness"] = snapshot
|
||||
data["subsystems"] = subsystems
|
||||
data["sampling"] = sampling
|
||||
data["mode"] = map[string]interface{}{
|
||||
"kind": modeKind,
|
||||
"updated_at": now,
|
||||
"age_seconds": int64(0),
|
||||
"reason": modeReason,
|
||||
"scope": modeScope,
|
||||
"source": freshness.SourceReported,
|
||||
"confidence": freshness.ConfidenceHigh,
|
||||
"provenance": freshness.ProvenanceMissionFeed,
|
||||
}
|
||||
}
|
||||
}
|
||||
if relays := FetchCCIPRelayHealths(ctx); relays != nil {
|
||||
data["ccip_relays"] = relays
|
||||
if ccip := primaryRelayHealth(relays); ccip != nil {
|
||||
@@ -142,5 +219,58 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
||||
}
|
||||
}
|
||||
}
|
||||
if mode, ok := data["mode"].(map[string]interface{}); ok {
|
||||
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
|
||||
mode["kind"] = "snapshot"
|
||||
mode["reason"] = "live_homepage_stream_not_attached"
|
||||
mode["scope"] = "relay_monitoring_homepage_card_only"
|
||||
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
|
||||
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
|
||||
"status": data["status"],
|
||||
"updated_at": now,
|
||||
"age_seconds": int64(0),
|
||||
"source": freshness.SourceReported,
|
||||
"confidence": freshness.ConfidenceHigh,
|
||||
"provenance": freshness.ProvenanceMissionFeed,
|
||||
"completeness": freshness.CompletenessComplete,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func valueOrNil[T any](value *T) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ageSinceRFC3339(value *string) any {
|
||||
if value == nil || *value == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, *value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
age := int64(time.Since(parsed).Seconds())
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return age
|
||||
}
|
||||
|
||||
func completenessStatus(value freshness.Completeness) string {
|
||||
switch value {
|
||||
case freshness.CompletenessComplete:
|
||||
return "operational"
|
||||
case freshness.CompletenessPartial:
|
||||
return "partial"
|
||||
case freshness.CompletenessStale:
|
||||
return "stale"
|
||||
default:
|
||||
return "unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -145,7 +146,50 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "")
|
||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
||||
|
||||
s := &Server{}
|
||||
s := &Server{
|
||||
freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
head := int64(16)
|
||||
txBlock := int64(12)
|
||||
distance := int64(4)
|
||||
return &freshness.Snapshot{
|
||||
ChainHead: freshness.Reference{
|
||||
BlockNumber: &head,
|
||||
Timestamp: &now,
|
||||
AgeSeconds: func() *int64 { v := int64(1); return &v }(),
|
||||
Source: freshness.SourceReported,
|
||||
Confidence: freshness.ConfidenceHigh,
|
||||
Provenance: freshness.ProvenanceRPC,
|
||||
Completeness: freshness.CompletenessComplete,
|
||||
},
|
||||
LatestIndexedTransaction: freshness.Reference{
|
||||
BlockNumber: &txBlock,
|
||||
Timestamp: &now,
|
||||
AgeSeconds: func() *int64 { v := int64(120); return &v }(),
|
||||
Source: freshness.SourceReported,
|
||||
Confidence: freshness.ConfidenceHigh,
|
||||
Provenance: freshness.ProvenanceTxIndex,
|
||||
Completeness: freshness.CompletenessPartial,
|
||||
},
|
||||
LatestNonEmptyBlock: freshness.Reference{
|
||||
BlockNumber: &txBlock,
|
||||
Timestamp: &now,
|
||||
AgeSeconds: func() *int64 { v := int64(120); return &v }(),
|
||||
DistanceFromHead: &distance,
|
||||
Source: freshness.SourceReported,
|
||||
Confidence: freshness.ConfidenceHigh,
|
||||
Provenance: freshness.ProvenanceTxIndex,
|
||||
Completeness: freshness.CompletenessPartial,
|
||||
},
|
||||
},
|
||||
&freshness.SummaryCompleteness{
|
||||
TransactionsFeed: freshness.CompletenessPartial,
|
||||
BlocksFeed: freshness.CompletenessComplete,
|
||||
},
|
||||
&freshness.Sampling{StatsGeneratedAt: &now},
|
||||
nil
|
||||
},
|
||||
}
|
||||
got := s.BuildBridgeStatusData(context.Background())
|
||||
ccip, ok := got["ccip_relay"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
@@ -156,6 +200,9 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
|
||||
probe, ok := ccip["url_probe"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, probe["ok"])
|
||||
require.Contains(t, got, "freshness")
|
||||
require.Contains(t, got, "subsystems")
|
||||
require.Contains(t, got, "mode")
|
||||
}
|
||||
|
||||
func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
|
||||
@@ -197,7 +244,11 @@ func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
|
||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "mainnet="+mainnet.URL+"/healthz,bsc="+bad.URL+"/healthz")
|
||||
|
||||
s := &Server{}
|
||||
s := &Server{
|
||||
freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
|
||||
return nil, nil, nil, nil
|
||||
},
|
||||
}
|
||||
got := s.BuildBridgeStatusData(context.Background())
|
||||
require.Equal(t, "degraded", got["status"])
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
"github.com/explorer/backend/libs/go-rpc-gateway"
|
||||
)
|
||||
|
||||
@@ -19,13 +20,18 @@ var track1HashPattern = regexp.MustCompile(`^0x[a-fA-F0-9]{64}$`)
|
||||
|
||||
// Server handles Track 1 endpoints (uses RPC gateway from lib)
|
||||
type Server struct {
|
||||
rpcGateway *gateway.RPCGateway
|
||||
rpcGateway *gateway.RPCGateway
|
||||
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error)
|
||||
}
|
||||
|
||||
// NewServer creates a new Track 1 server
|
||||
func NewServer(rpcGateway *gateway.RPCGateway) *Server {
|
||||
func NewServer(
|
||||
rpcGateway *gateway.RPCGateway,
|
||||
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error),
|
||||
) *Server {
|
||||
return &Server{
|
||||
rpcGateway: rpcGateway,
|
||||
rpcGateway: rpcGateway,
|
||||
freshnessLoader: freshnessLoader,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user