mirror of
https://github.com/luxfi/monitoring.git
synced 2026-10-30 15:24:48 +00:00
The validator-health exporter and the rpc-tip-gate were both stdlib-only
Python scripts living in ConfigMaps on a stock python image. That choice
bought one thing — no build step — and it cost the backend standard: the
serving side of this stack is Go, and these two are serving processes.
internal/luxd — what one node says it believes (tip, finalized, peers,
bootstrapped, health-with-check-names). /v1 only.
internal/kube — the four apiserver calls fleet health makes. Not
client-go: a scheme, a codec and an informer machinery
to unmarshal four fields is not a dependency, it is a
liability.
internal/fleet — what the numbers MEAN, separated from the process that
serves them, so a metric's meaning can be tested
without a cluster.
Proven, not asserted. Both implementations were pointed at one fake
cluster and diffed:
exporter : 234 lines, 22 metric families — byte-for-byte identical.
tip-gate : same 5 decisions, same 3 merge-patches, byte-for-byte.
The differential earned its keep: the gate had been ported to
eth_getBlockByNumber when the deployed one calls eth_blockNumber. Same
answer today, a different call tomorrow. Fixed to match.
Two deliberate improvements over the Python, neither a scrape-visible
change: exposition series are sorted (Python's thread-completion order
made two scrapes of a steady fleet textually different), and the gate
refuses to start on a missing setting rather than defaulting quietly.
Co-authored-by: Hanzo Dev <[email protected]>
253 lines
14 KiB
Go
253 lines
14 KiB
Go
package fleet
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ── render: the exposition contract, no network ───────────────────────────────
|
|
|
|
func u(v uint64) *uint64 { return &v }
|
|
func i(v int) *int { return &v }
|
|
func b(v bool) *bool { return &v }
|
|
|
|
// One mainnet-shaped poll with every interesting condition present at once: a
|
|
// fork at the tip, a lagging node with two failing health checks, a node that
|
|
// never answered, and a pod that says Ready while its RPC is dead.
|
|
func mixedSamples() []sample {
|
|
return []sample{
|
|
{Net: 0, Idx: 0, Up: true, Healthy: true, Boot: true, Peers: i(4),
|
|
AccH: u(100), AccHash: "0xaaa", AccTS: u(1753500000), FinH: u(100), FinHash: "0xaaa",
|
|
Restarts: i(0), Ready: b(true), Logs: map[string]int{"fatal": 0, "equivocation": 0, "cert_rejected": 0}},
|
|
{Net: 0, Idx: 1, Up: true, Healthy: true, Boot: true, Peers: i(4),
|
|
AccH: u(100), AccHash: "0xbbb", AccTS: u(1753500000), FinH: u(100), FinHash: "0xbbb",
|
|
Restarts: i(2), Ready: b(true), Logs: map[string]int{"fatal": 1, "equivocation": 1, "cert_rejected": 2}},
|
|
{Net: 0, Idx: 2, Up: true, Healthy: false, Boot: true, Peers: i(3), Failing: []string{"C", "P"},
|
|
AccH: u(98), AccHash: "0xccc", AccTS: u(1753499990), FinH: u(97), FinHash: "0xddd",
|
|
Restarts: i(1), Ready: b(true), Logs: map[string]int{"fatal": 0, "equivocation": 0, "cert_rejected": 0}},
|
|
{Net: 0, Idx: 3, Ready: b(true), Restarts: i(0),
|
|
Logs: map[string]int{"fatal": 0, "equivocation": 0, "cert_rejected": 0}},
|
|
{Net: 0, Idx: 4, Logs: map[string]int{"fatal": 0, "equivocation": 0, "cert_rejected": 0}},
|
|
}
|
|
}
|
|
|
|
func TestExpositionIsTheDeclaredContract(t *testing.T) {
|
|
got := normalise(render(mixedSamples(), 250*time.Millisecond, 5))
|
|
want := `# HELP lux_validator_exporter_up Exporter liveness.
|
|
# TYPE lux_validator_exporter_up gauge
|
|
lux_validator_exporter_up 1
|
|
# HELP lux_validator_poll_duration_seconds Time to poll all validators.
|
|
# TYPE lux_validator_poll_duration_seconds gauge
|
|
lux_validator_poll_duration_seconds <DUR>
|
|
# HELP lux_validator_up 1 if C-Chain RPC reachable (process up).
|
|
# TYPE lux_validator_up gauge
|
|
lux_validator_up{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 1
|
|
lux_validator_up{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 1
|
|
lux_validator_up{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 1
|
|
lux_validator_up{network="mainnet",instance="luxd-3",brand="lux",chain="C"} 0
|
|
lux_validator_up{network="mainnet",instance="luxd-4",brand="lux",chain="C"} 0
|
|
# HELP lux_validator_healthy 1 if /v1/health reports healthy.
|
|
# TYPE lux_validator_healthy gauge
|
|
lux_validator_healthy{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 1
|
|
lux_validator_healthy{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 1
|
|
lux_validator_healthy{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 0
|
|
lux_validator_healthy{network="mainnet",instance="luxd-3",brand="lux",chain="C"} 0
|
|
lux_validator_healthy{network="mainnet",instance="luxd-4",brand="lux",chain="C"} 0
|
|
# HELP lux_validator_bootstrapped 1 if C-Chain bootstrapped.
|
|
# TYPE lux_validator_bootstrapped gauge
|
|
lux_validator_bootstrapped{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 1
|
|
lux_validator_bootstrapped{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 1
|
|
lux_validator_bootstrapped{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 1
|
|
lux_validator_bootstrapped{network="mainnet",instance="luxd-3",brand="lux",chain="C"} 0
|
|
lux_validator_bootstrapped{network="mainnet",instance="luxd-4",brand="lux",chain="C"} 0
|
|
# HELP lux_validator_peers Connected peer count.
|
|
# TYPE lux_validator_peers gauge
|
|
lux_validator_peers{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 4
|
|
lux_validator_peers{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 4
|
|
lux_validator_peers{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 3
|
|
# HELP lux_validator_c_block_height Accepted C-Chain tip height.
|
|
# TYPE lux_validator_c_block_height gauge
|
|
lux_validator_c_block_height{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 100
|
|
lux_validator_c_block_height{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 100
|
|
lux_validator_c_block_height{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 98
|
|
# HELP lux_validator_c_block_timestamp Unix timestamp carried by the accepted C-Chain tip block.
|
|
# TYPE lux_validator_c_block_timestamp gauge
|
|
lux_validator_c_block_timestamp{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 1753500000
|
|
lux_validator_c_block_timestamp{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 1753500000
|
|
lux_validator_c_block_timestamp{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 1753499990
|
|
# HELP lux_validator_c_finalized_height Finalized C-Chain height.
|
|
# TYPE lux_validator_c_finalized_height gauge
|
|
lux_validator_c_finalized_height{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 100
|
|
lux_validator_c_finalized_height{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 100
|
|
lux_validator_c_finalized_height{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 97
|
|
# HELP lux_validator_health_check_failed 1 per named luxd health check currently failing (the reason behind healthy=0).
|
|
# TYPE lux_validator_health_check_failed gauge
|
|
lux_validator_health_check_failed{network="mainnet",instance="luxd-2",brand="lux",check="C"} 1
|
|
lux_validator_health_check_failed{network="mainnet",instance="luxd-2",brand="lux",check="P"} 1
|
|
# HELP lux_validator_ready 1 if pod Ready condition is True.
|
|
# TYPE lux_validator_ready gauge
|
|
lux_validator_ready{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 1
|
|
lux_validator_ready{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 1
|
|
lux_validator_ready{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 1
|
|
lux_validator_ready{network="mainnet",instance="luxd-3",brand="lux",chain="C"} 1
|
|
# HELP lux_validator_restarts_total Cumulative container restarts.
|
|
# TYPE lux_validator_restarts_total counter
|
|
lux_validator_restarts_total{network="mainnet",instance="luxd-0",brand="lux",chain="C"} 0
|
|
lux_validator_restarts_total{network="mainnet",instance="luxd-1",brand="lux",chain="C"} 2
|
|
lux_validator_restarts_total{network="mainnet",instance="luxd-2",brand="lux",chain="C"} 1
|
|
lux_validator_restarts_total{network="mainnet",instance="luxd-3",brand="lux",chain="C"} 0
|
|
# HELP lux_validator_log_matches_total Cumulative luxd log lines matching a pattern.
|
|
# TYPE lux_validator_log_matches_total counter
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-0",brand="lux",pattern="cert_rejected"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-0",brand="lux",pattern="equivocation"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-0",brand="lux",pattern="fatal"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-1",brand="lux",pattern="cert_rejected"} 2
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-1",brand="lux",pattern="equivocation"} 1
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-1",brand="lux",pattern="fatal"} 1
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-2",brand="lux",pattern="cert_rejected"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-2",brand="lux",pattern="equivocation"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-2",brand="lux",pattern="fatal"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-3",brand="lux",pattern="cert_rejected"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-3",brand="lux",pattern="equivocation"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-3",brand="lux",pattern="fatal"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-4",brand="lux",pattern="cert_rejected"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-4",brand="lux",pattern="equivocation"} 0
|
|
lux_validator_log_matches_total{network="mainnet",instance="luxd-4",brand="lux",pattern="fatal"} 0
|
|
# HELP lux_network_validators_total Expected validators in the set.
|
|
# TYPE lux_network_validators_total gauge
|
|
lux_network_validators_total{network="mainnet",brand="lux"} 5
|
|
# HELP lux_network_validators_up Validators with RPC reachable.
|
|
# TYPE lux_network_validators_up gauge
|
|
lux_network_validators_up{network="mainnet",brand="lux"} 3
|
|
# HELP lux_network_validators_healthy Validators reporting healthy.
|
|
# TYPE lux_network_validators_healthy gauge
|
|
lux_network_validators_healthy{network="mainnet",brand="lux"} 2
|
|
# HELP lux_network_validators_ready Validators with pod Ready.
|
|
# TYPE lux_network_validators_ready gauge
|
|
lux_network_validators_ready{network="mainnet",brand="lux"} 4
|
|
# HELP lux_network_finalized_fork 1 if validators report different finalized hashes at the same height.
|
|
# TYPE lux_network_finalized_fork gauge
|
|
lux_network_finalized_fork{network="mainnet",brand="lux"} 1
|
|
# HELP lux_network_preferred_fork 1 if validators report different latest hashes at the same height.
|
|
# TYPE lux_network_preferred_fork gauge
|
|
lux_network_preferred_fork{network="mainnet",brand="lux"} 1
|
|
# HELP lux_network_finalized_height_spread max-min finalized height.
|
|
# TYPE lux_network_finalized_height_spread gauge
|
|
lux_network_finalized_height_spread{network="mainnet",brand="lux"} 3
|
|
# HELP lux_network_block_height_spread max-min accepted height.
|
|
# TYPE lux_network_block_height_spread gauge
|
|
lux_network_block_height_spread{network="mainnet",brand="lux"} 2
|
|
# HELP lux_network_c_tip_age_seconds Age in seconds of the freshest C-Chain tip in the network (wall clock minus the tip block's own timestamp). Grows without bound on a frozen fleet.
|
|
# TYPE lux_network_c_tip_age_seconds gauge
|
|
lux_network_c_tip_age_seconds{network="mainnet",brand="lux"} <AGE>
|
|
# HELP lux_network_tip_hash_variants Largest number of DISTINCT accepted block hashes reported at any one height. >1 is a fork.
|
|
# TYPE lux_network_tip_hash_variants gauge
|
|
lux_network_tip_hash_variants{network="mainnet",brand="lux"} 2
|
|
# HELP lux_network_ready_but_rpc_dead Validators whose pod reports Ready while their C-Chain RPC does not answer — the readiness-lies failure class.
|
|
# TYPE lux_network_ready_but_rpc_dead gauge
|
|
lux_network_ready_but_rpc_dead{network="mainnet",brand="lux"} 1
|
|
`
|
|
if got != want {
|
|
t.Fatalf("exposition drift:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
|
}
|
|
}
|
|
|
|
// A network where nothing answered must leave tip age ABSENT, not zero: a fake
|
|
// age of zero reads as a perfectly fresh chain, which is the exact lie this
|
|
// metric exists to prevent.
|
|
func TestTotalOutageLeavesTipAgeAbsent(t *testing.T) {
|
|
out := render([]sample{{Net: 2, Idx: 0}, {Net: 2, Idx: 1}}, time.Second, 5)
|
|
if strings.Contains(out, "lux_network_c_tip_age_seconds") {
|
|
t.Fatalf("tip age must be absent when no node answered:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `lux_network_validators_up{network="devnet",brand="lux"} 0`) {
|
|
t.Fatalf("validators_up must still report zero:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// An agreeing fleet is not a fork, however many validators there are.
|
|
func TestAgreementIsNotAFork(t *testing.T) {
|
|
ss := []sample{
|
|
{Net: 0, Idx: 0, Up: true, AccH: u(7), AccHash: "0xa", FinH: u(7), FinHash: "0xa"},
|
|
{Net: 0, Idx: 1, Up: true, AccH: u(7), AccHash: "0xa", FinH: u(7), FinHash: "0xa"},
|
|
}
|
|
out := render(ss, time.Second, 2)
|
|
for _, want := range []string{
|
|
`lux_network_preferred_fork{network="mainnet",brand="lux"} 0`,
|
|
`lux_network_finalized_fork{network="mainnet",brand="lux"} 0`,
|
|
`lux_network_tip_hash_variants{network="mainnet",brand="lux"} 1`,
|
|
`lux_network_block_height_spread{network="mainnet",brand="lux"} 0`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("missing %q in:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── log patterns: the same lines must score the same counts ───────────────────
|
|
|
|
func TestLogPatternsScoreTheLinesTheyWereTunedFor(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
line string
|
|
want map[string]bool
|
|
}{
|
|
{"fatal EQUIVOCATION: conflicting finality cert at height 7",
|
|
map[string]bool{"fatal": true, "equivocation": true, "cert_rejected": true}},
|
|
{"build block failure, retrying",
|
|
map[string]bool{"fatal": false, "equivocation": false, "cert_rejected": false}},
|
|
{"certificate rejected by peer",
|
|
map[string]bool{"fatal": false, "equivocation": false, "cert_rejected": true}},
|
|
{"panic: nil map write",
|
|
map[string]bool{"fatal": true, "equivocation": false, "cert_rejected": false}},
|
|
{"everything is fine",
|
|
map[string]bool{"fatal": false, "equivocation": false, "cert_rejected": false}},
|
|
} {
|
|
for _, p := range logPatterns {
|
|
if got := p.RE.MatchString(tc.line); got != tc.want[p.Name] {
|
|
t.Errorf("%s on %q: got %v want %v", p.Name, tc.line, got, tc.want[p.Name])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── differential: same fake cluster, both exporters ───────────────────────────
|
|
|
|
// TestExpositionAgainstFakeCluster drives the real poll path against the shared
|
|
// fake cluster the Python exporter is also pointed at, and prints the result so
|
|
// the two can be diffed. Skipped unless FLEET_FAKE names that cluster.
|
|
func TestExpositionAgainstFakeCluster(t *testing.T) {
|
|
base := os.Getenv("FLEET_FAKE")
|
|
if base == "" {
|
|
t.Skip("set FLEET_FAKE=http://host:port to run the differential")
|
|
}
|
|
e := NewExporter(Config{
|
|
Replicas: 5,
|
|
KubeBase: base,
|
|
NodeBase: func(net, ns, pod string, port int) string {
|
|
return fmt.Sprintf("%s/fake/%s/%s", base, net, strings.TrimPrefix(pod, "luxd-"))
|
|
},
|
|
})
|
|
out := e.Once(context.Background())
|
|
if err := os.WriteFile(os.Getenv("FLEET_OUT"), []byte(normalise(out)), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// normalise blanks the two values that are a function of wall-clock time. Tip
|
|
// age and poll duration are asserted for PRESENCE and label set; their value
|
|
// cannot be compared across two processes that ran a second apart.
|
|
var (
|
|
durRE = regexp.MustCompile(`(?m)^lux_validator_poll_duration_seconds .*$`)
|
|
ageRE = regexp.MustCompile(`(?m)^(lux_network_c_tip_age_seconds\{[^}]*\}) \d+$`)
|
|
)
|
|
|
|
func normalise(s string) string {
|
|
s = durRE.ReplaceAllString(s, "lux_validator_poll_duration_seconds <DUR>")
|
|
return ageRE.ReplaceAllString(s, "$1 <AGE>")
|
|
}
|