Real-time analysis of network utilization of Ethereum Consensus Layer nodes.
Live dashboard: xray.ethp2p.dev
Xray wraps the libp2p Host to capture stream-level traffic without modifying application code.
A separate backend process decodes gossipsub messages, extracts SSZ slot numbers, and aggregates per-slot bandwidth breakdowns.
A Solid.js dashboard ("Ethereum Xray") renders the data in real time.
┌───────────────────────────────────────────────────────────────┐
│ Ethereum CL client │
│ (Prysm, Lighthouse, etc.) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ xray wiretap library │ │
│ │ Wraps libp2p Host, intercepts streams/connections │ │
│ │ Forwards raw bytes via ingest protocol │ │
│ └──────────────────────┬──────────────────────────────────┘ │
└─────────────────────────┼─────────────────────────────────────┘
│ Unix socket / TCP
│ (ClientHello -> ServerHello -> Envelopes)
v
┌─────────────────────────────────────────────────────────────┐
│ Backend (cmd/xray) │
│ │
│ ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌────────────┐ │
│ │ Ingest │->│ Processor │->│ Storage │ │ HTTP/WS │ │
│ │ listener │ │ (per-src) │ │ (SQLite) │ │ server │ │
│ └──────────┘ └───────────┘ └──────────┘ └─────┬──────┘ │
│ │ │
│ gossipsub/ --- RPC parser │ │
│ eth/ ────────- SSZ decoder, slot clock │ │
└───────────────────────────────────────────────────┼─────────┘
│
┌───────v─────────┐
│ Xray Dashboard │
│ (Solid.js) │
└─────────────────┘
The wiretap is a library that clients embed. It wraps the libp2p Host, intercepts every Read/Write on every stream, and forwards raw byte chunks over a lightweight ingest protocol to the backend. The probe has no Ethereum-specific logic; it sends opaque bytes.
The backend is a standalone binary (cmd/xray). It accepts probe connections, reassembles gossipsub RPC frames, decodes SSZ payloads to extract slot numbers and block metadata, then aggregates traffic into 100ms time buckets per slot. It serves a REST + WebSocket API for the dashboard and persists finalized slots and source metadata to SQLite.
The dashboard is a Solid.js single-page app that connects to the backend over WebSocket for live slot updates and REST for historical data.
docker compose up --buildThe backend listens on port 9100. Mount the probe's Unix socket directory as a volume (see compose.yaml).
Start the backend:
go build -o xray ./cmd/xray
./xray --ingest=/tmp/xray.sock --listen=127.0.0.1:9100The backend uses github.com/mattn/go-sqlite3, so local builds need CGO enabled
and a working C compiler.
Start the dashboard dev server:
cd dashboard && bun install && bun run devOpen http://localhost:5173. Vite proxies API requests to the backend on :9100.
import "github.com/ethp2p/xray"
ih, err := xray.Wiretap(h,
xray.WithIngestAddr("/tmp/xray.sock"),
xray.WithClientName("prysm"),
xray.WithWaitForAttach(),
)Prysm's fork supports this via --instrument-socket and --instrument-file flags.
*.go Wiretap producer SDK (host wrapper, sinks, emitter) at module root
api/ Shared JSON DTOs for REST/WebSocket responses
cmd/xray/ Backend binary entrypoint
internal/eth/ Ethereum slot clock and SSZ extraction
internal/gossipsub/ Gossipsub RPC parser
internal/ingest/ Probe connection listener and ingest sessions
internal/processor/ Per-source aggregation and finalized slot production
internal/server/ REST/WebSocket API server
internal/sources/ Probe source registry
internal/storage/ SQLite persistence for sources and finalized slots
proto/wiretap/ Protobuf definitions and generated ingest messages
proto/wiretap/wire/ Typed length-delimited ingest protocol codec
itest/ Integration tests (gossipsub decoding, introspector E2E)
dashboard/ Solid.js web dashboard ("Ethereum Xray")
clients/ Example or integration client code
gen/ Generated support code
The probe wraps a go-libp2p host transparently:
import "github.com/ethp2p/xray"
host, _ := libp2p.New(...)
ih, err := xray.Wiretap(host,
xray.WithIngestAddr("/tmp/xray.sock"),
xray.WithClientName("my-client/v1.0"),
xray.WithWaitForAttach(), // block until backend connects
xray.WithSinkFile("/var/log/xray.trace"), // optional local trace file
xray.WithDecoder(gossipsub.Decoder{}.Match, gossipsub.Decoder{}.New),
xray.WithOnMessage(func(streamID uint32, protocol string) xray.OnMessage {
return func(msg xray.DecodedMessage) {
// handle decoded messages off the hot path
}
}),
)
defer ih.Close()xray.Wiretap returns a *xray.Host that satisfies host.Host. Existing code works unchanged; all stream reads/writes are intercepted and forwarded.
Backend CLI flags (cmd/xray):
| Flag | Default | Description |
|---|---|---|
--ingest |
/tmp/xray.sock |
Ingest listener address (Unix path or host:port) |
--listen |
127.0.0.1:9100 |
HTTP listen address for REST/WS API |
--data-dir |
~/.xray/data |
Persistence directory for slot data |
--retention-days |
30 |
Slot retention period in days |
--genesis-unix |
1606824023 |
Beacon chain genesis Unix timestamp |
--seconds-per-slot |
12 |
Beacon chain seconds per slot |
--static-dir |
(none) | Serve dashboard static files from this directory |
| Method | Path | Description |
|---|---|---|
| GET | /api/slots?source=X&limit=N&search=Q |
List slot summaries (live + persisted) |
| GET | /api/slots/:slot?source=X |
Slot detail with time buckets and breakdown |
| GET | /api/sources |
List connected probe sources |
| GET | /api/peers?source=X |
List peers with connection metadata |
| GET | /api/search?source=X&from_slot=A&to_slot=B&limit=N |
Search persisted slots by range |
Connect to /api/ws?source=X. The server sends:
snapshoton connect (withcurrent_slot)slot_batchevery 100ms with updated slot summaries and current slot
Elapsed slots and source metadata are written to SQLite at <data-dir>/xray.db.
Slot summaries and details are stored in SQLite JSONB columns with scalar
source_id and slot columns for indexed lookup.
On startup, legacy <data-dir>/<source_id>/source.json and slots/*.json data
is imported into SQLite once. Malformed legacy JSON files are moved under
<data-dir>/legacy-failed/, and successfully imported legacy files are removed.
Legacy index/*.jsonl files are redundant because summaries are reconstructed
from slot details. Retention pruning runs daily, removing slot rows older than
--retention-days.
# Build everything
go build ./...
# Run all tests (unit + integration)
go test ./... -timeout 120s
# Dashboard dev server (hot reload)
cd dashboard && bun install && bun run dev
# Dashboard production build
cd dashboard && bun run build
# Regenerate protobuf (requires buf, protoc-gen-go, protoc-gen-connect-go)
buf generateMIT