mirror of
https://github.com/luxfi/monitoring.git
synced 2026-10-30 15:24:48 +00:00
193 lines
5.2 KiB
Go
193 lines
5.2 KiB
Go
// Package luxd asks one luxd node what it thinks is true.
|
|
//
|
|
// Every answer is optional. A node that will not talk is not an error to be
|
|
// propagated — it is a fact about the fleet, and it is the fact the caller most
|
|
// needs. So the zero value of every field means "did not say", and the caller
|
|
// decides what that implies.
|
|
package luxd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Client speaks to one node's HTTP surface. /v1 only: /ext is the dead prefix.
|
|
type Client struct {
|
|
Base string
|
|
HTTP *http.Client
|
|
// CPath is the C-Chain JSON-RPC path. Empty means the canonical one.
|
|
CPath string
|
|
}
|
|
|
|
// cPath is the C-Chain JSON-RPC path this client will use.
|
|
func (c *Client) cPath() string {
|
|
if c.CPath == "" {
|
|
return "/v1/chain/C/rpc"
|
|
}
|
|
return c.CPath
|
|
}
|
|
|
|
// Block is the part of an eth_getBlockByNumber result fleet health reads.
|
|
type Block struct {
|
|
Number uint64
|
|
Hash string
|
|
Timestamp uint64
|
|
}
|
|
|
|
// Health is luxd's /v1/health answer, including the named checks. luxd returns
|
|
// HTTP 503 *with* a JSON body when a check fails, so the body is read off the
|
|
// error response too — a handler that only catches the transport error throws
|
|
// the diagnosis away and leaves every node reading a reasonless healthy=0.
|
|
type Health struct {
|
|
Healthy bool `json:"healthy"`
|
|
Checks map[string]struct {
|
|
Healthy *bool `json:"healthy"`
|
|
} `json:"checks"`
|
|
}
|
|
|
|
// FailedChecks is the sorted-by-caller list of check names currently failing —
|
|
// the reason behind healthy=0.
|
|
func (h *Health) FailedChecks() []string {
|
|
var out []string
|
|
for k, v := range h.Checks {
|
|
if v.Healthy != nil && !*v.Healthy {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *Client) post(ctx context.Context, path string, body []byte) ([]byte, int, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(resp.Body)
|
|
return b, resp.StatusCode, err
|
|
}
|
|
|
|
// rpc issues one JSON-RPC call and unwraps "result" into out.
|
|
func (c *Client) rpc(ctx context.Context, path, method string, params any, out any) error {
|
|
body, err := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0", "id": 1, "method": method, "params": params,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, _, err := c.post(ctx, path, body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var env struct {
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return err
|
|
}
|
|
if len(env.Result) == 0 || string(env.Result) == "null" {
|
|
return errNoResult
|
|
}
|
|
return json.Unmarshal(env.Result, out)
|
|
}
|
|
|
|
type constErr string
|
|
|
|
func (e constErr) Error() string { return string(e) }
|
|
|
|
const errNoResult = constErr("luxd: empty result")
|
|
|
|
// hexInt accepts both the 0x form the EVM returns and a bare decimal.
|
|
func hexInt(s string) (uint64, error) {
|
|
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
|
|
return strconv.ParseUint(s[2:], 16, 64)
|
|
}
|
|
return strconv.ParseUint(s, 10, 64)
|
|
}
|
|
|
|
// CBlock reads a C-Chain block by tag ("latest", "finalized").
|
|
func (c *Client) CBlock(ctx context.Context, tag string) (*Block, error) {
|
|
var r struct {
|
|
Number string `json:"number"`
|
|
Hash string `json:"hash"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
if err := c.rpc(ctx, c.cPath(), "eth_getBlockByNumber", []any{tag, false}, &r); err != nil {
|
|
return nil, err
|
|
}
|
|
n, err := hexInt(r.Number)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ts, err := hexInt(r.Timestamp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Block{Number: n, Hash: r.Hash, Timestamp: ts}, nil
|
|
}
|
|
|
|
// BlockNumber is the accepted C-Chain head height in one round trip. The tip
|
|
// gate uses this rather than fetching a whole block: it runs every few seconds
|
|
// against every validator, and it only ever needed the number.
|
|
func (c *Client) BlockNumber(ctx context.Context) (uint64, error) {
|
|
var s string
|
|
if err := c.rpc(ctx, c.cPath(), "eth_blockNumber", []any{}, &s); err != nil {
|
|
return 0, err
|
|
}
|
|
return hexInt(s)
|
|
}
|
|
|
|
// Peers is the connected peer count.
|
|
func (c *Client) Peers(ctx context.Context) (int, error) {
|
|
var r struct {
|
|
NumPeers json.Number `json:"numPeers"`
|
|
}
|
|
if err := c.rpc(ctx, "/v1/info", "info.peers", map[string]any{}, &r); err != nil {
|
|
return 0, err
|
|
}
|
|
n, err := r.NumPeers.Int64()
|
|
return int(n), err
|
|
}
|
|
|
|
// Bootstrapped reports whether the named chain finished bootstrapping.
|
|
func (c *Client) Bootstrapped(ctx context.Context, chain string) (bool, error) {
|
|
var r struct {
|
|
IsBootstrapped bool `json:"isBootstrapped"`
|
|
}
|
|
err := c.rpc(ctx, "/v1/info", "info.isBootstrapped", map[string]any{"chain": chain}, &r)
|
|
return r.IsBootstrapped, err
|
|
}
|
|
|
|
// Health reads /v1/health, keeping the body on a 503 so the failing check
|
|
// names survive.
|
|
func (c *Client) Health(ctx context.Context) (*Health, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/v1/health", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var h Health
|
|
if err := json.Unmarshal(b, &h); err != nil {
|
|
return nil, err
|
|
}
|
|
return &h, nil
|
|
}
|