Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9ce49d5
feat(telemetry): encode block events as Silver's block_events rows
MegaRedHand Nov 2, 2026
7ca53de
feat(telemetry): batch block events into ClickHouse over HTTP
MegaRedHand Nov 2, 2026
ffadcba
feat(telemetry): global sink installed once, like the metrics registry
MegaRedHand Nov 2, 2026
c8e7597
test(telemetry): make the schema-rerun test fail without the re-arm
MegaRedHand Nov 2, 2026
4789f30
fix(telemetry): drop a batch ClickHouse rejects instead of retrying i…
MegaRedHand Nov 2, 2026
66bc494
fix(telemetry): log refusals once per streak and correct stale descri…
MegaRedHand Nov 2, 2026
ce0b8cd
feat(blockchain): write each import's timeline as block_events rows
MegaRedHand Nov 2, 2026
9192bc4
feat(blockchain): file arrival rows and beacon attestable after the head
MegaRedHand Nov 2, 2026
caf7930
fix(blockchain): write da_available only for blocks that waited on co…
MegaRedHand Nov 2, 2026
ad2919d
test(blockchain): pin the cascade arrival and the columns-required fl…
MegaRedHand Nov 2, 2026
102aaf2
feat(p2p): write column_recv and column_validated rows
MegaRedHand Nov 2, 2026
9298991
refactor(p2p): column rows hash once, say what they dedup, and type t…
MegaRedHand Nov 2, 2026
aa45c1f
refactor(p2p): build a column's row once
MegaRedHand Nov 2, 2026
d138d32
feat(cli): --telemetry.clickhouse-url turns on block_events rows
MegaRedHand Nov 2, 2026
9e0d8ce
fix(cli): keep the ClickHouse URL's credentials out of Debug and errors
MegaRedHand Nov 2, 2026
d4ed0fe
test(cli): cover an unparseable ClickHouse URL and name the env var i…
MegaRedHand Nov 2, 2026
d3742b0
docs: block telemetry rows, flags, metrics and stage names
MegaRedHand Nov 2, 2026
53903f5
fix(blockchain): write attestable only when the head computation succ…
MegaRedHand Nov 3, 2026
119e70e
docs(telemetry): list every stage and correct which blocks write rows
MegaRedHand Nov 3, 2026
f2eb961
fix(telemetry): give the el rows their own instants and match availab…
MegaRedHand Nov 3, 2026
b79efc9
Merge branch 'main' into feat/clickhouse-block-events
MegaRedHand Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ crates/
├─ crypto/ # XMSS sign/verify + aggregation (leanVM wrapper)
├─ ssz-tree/ # Persistent Merkle tree (List/Vector) behind the beacon registry and balances
├─ metrics/ # Prometheus re-exports, TimingGuard, gather utilities
├─ telemetry/ # ClickHouse block_events rows (opt-in)
└─ test-fixtures/ # Spec-fixture loading (prod dep of rpc's Hive test driver)
net/
├─ api/ # Actor protocol traits wiring BlockChain ↔ P2P
Expand Down Expand Up @@ -590,6 +591,17 @@ default** on its two SSZ endpoints and bare integers in JSON, because

See [`docs/rpc.md`](docs/rpc.md) for the full reference: CLI flags and defaults, the lean API endpoints (health, finalized state/block, justified checkpoint, blocks by root/slot, fork-choice tree + D3.js UI, runtime aggregator toggle), the Beacon API endpoints and the three ids they refuse, the metrics/debug endpoints (Prometheus `/metrics`, jemalloc heap profiling), the Hive test-driver endpoints, plus request/response shapes, status codes, and content types.

## Block telemetry (`crates/common/telemetry`)

`--telemetry.clickhouse-url` writes Silver's `block_events` rows (one per point in a block's
life, per data column arrival) to ClickHouse over HTTP. A global sink, installed in `run_node`
before `P2P::spawn`; `ethlambda_telemetry::emit(|| ...)` builds nothing when it is off. Rows
come from `BlockImportReport::telemetry_events` (final pass only, `imported_here`),
`CascadeTelemetry` (arrival rows, and beacon `attestable` at the per-arrival head
recomputation), and p2p's column rows: `column_checks::column_rows` (after the check), and
`passed_rows` called directly by the gossip-accept path and by this node's own fulu columns. See
[`docs/telemetry.md`](docs/telemetry.md).

## Beacon Chain types (`crates/common/types/src/beacon/`)

`ethlambda-types` carries the **Ethereum Beacon Chain** containers (phase0
Expand Down
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ members = [
"crates/common/crypto",
"crates/common/ssz-tree",
"crates/common/metrics",
"crates/common/telemetry",
"crates/common/test-fixtures",
"crates/common/types",
"crates/net/api",
Expand Down Expand Up @@ -72,6 +73,7 @@ ethlambda-state-transition = { path = "crates/blockchain/state_transition" }
ethlambda-crypto = { path = "crates/common/crypto" }
ethlambda-ssz-tree = { path = "crates/common/ssz-tree" }
ethlambda-metrics = { path = "crates/common/metrics" }
ethlambda-telemetry = { path = "crates/common/telemetry" }
ethlambda-test-fixtures = { path = "crates/common/test-fixtures" }
ethlambda-types = { path = "crates/common/types" }
ethlambda-network-api = { path = "crates/net/api" }
Expand Down
1 change: 1 addition & 0 deletions bin/ethlambda/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ ethlambda-state-transition.workspace = true
ethlambda-types.workspace = true
ethlambda-rpc.workspace = true
ethlambda-storage.workspace = true
ethlambda-telemetry.workspace = true
ethlambda-validator.workspace = true

# Parallel XMSS keygen for the real-crypto benchmark corpus.
Expand Down
156 changes: 156 additions & 0 deletions bin/ethlambda/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,67 @@ use ethlambda_types::beacon::constants::SAFE_SLOTS_TO_IMPORT_OPTIMISTICALLY;
use std::net::IpAddr;
use std::path::PathBuf;

/// The ClickHouse endpoint, wrapped so the options' derived `Debug` cannot
/// print its credentials: `reqwest::Url`'s own `Debug` includes the password
/// and the query.
#[derive(Clone)]
pub(crate) struct ClickHouseUrl(reqwest::Url);

impl ClickHouseUrl {
pub(crate) fn into_inner(self) -> reqwest::Url {
self.0
}
}

impl std::fmt::Debug for ClickHouseUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClickHouseUrl")
.field("scheme", &self.0.scheme())
.field("host", &self.0.host_str())
.field("port", &self.0.port_or_known_default())
.finish()
}
}

/// Parses `--telemetry.clickhouse-url`.
///
/// A plain `value_parser` function would leave clap to print
/// `invalid value '<raw>'` around its message, and the value may carry a
/// password. A `TypedValueParser` builds the whole `clap::Error` itself, so
/// the raw value never reaches the output.
#[derive(Clone)]
struct ClickHouseUrlParser;

impl clap::builder::TypedValueParser for ClickHouseUrlParser {
type Value = ClickHouseUrl;

fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<ClickHouseUrl, clap::Error> {
let reject = |why: &str| {
let flag = arg.map_or_else(String::new, |arg| arg.to_string());
cmd.clone().error(
clap::error::ErrorKind::ValueValidation,
format!(
"invalid {flag} (or ETHLAMBDA_TELEMETRY_CLICKHOUSE_URL): {why} \
(the value is not shown, it may hold a password)"
),
)
};
let raw = value.to_str().ok_or_else(|| reject("not valid UTF-8"))?;
let url = reqwest::Url::parse(raw).map_err(|_| reject("not a valid URL"))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(reject(
"only http and https are supported (ClickHouse's HTTP interface, usually port 8123)",
));
}
Ok(ClickHouseUrl(url))
}
}

/// Flags every sub-command takes, with the same meaning on each.
///
/// `--node-key`, `--bootnodes` and `--checkpoint-sync-url` used to be declared
Expand Down Expand Up @@ -94,6 +155,28 @@ pub(crate) struct CommonOptions {
/// directory, a URL, nor a loaded network's genesis, startup aborts.
#[arg(long, value_delimiter = ',')]
pub(crate) checkpoint_sync_url: Vec<String>,
/// ClickHouse HTTP endpoint that per-block telemetry rows are written to
/// (Silver's `block_events` table), e.g. `http://127.0.0.1:8123`.
///
/// Off when unset. Credentials go in the URL (`http://user:pass@host:8123`)
/// and are sent as basic auth; a `?database=` parameter passes through.
/// A URL with a password is better passed through the environment
/// variable, since any local user can read a process's command line.
/// See `docs/telemetry.md`.
#[arg(
long = "telemetry.clickhouse-url",
env = "ETHLAMBDA_TELEMETRY_CLICKHOUSE_URL",
hide_env_values = true,
value_parser = ClickHouseUrlParser
)]
pub(crate) telemetry_clickhouse_url: Option<ClickHouseUrl>,
/// `meta_client_name` on every telemetry row.
///
/// Defaults to `--node-id` on `node`, since devnets run several nodes per
/// host, and to `ethlambda@<hostname>` on `beacon`, which keeps its rows
/// apart from another client writing the same table from the same host.
#[arg(long = "telemetry.client-name", requires = "telemetry_clickhouse_url")]
pub(crate) telemetry_client_name: Option<String>,
#[command(flatten)]
pub(crate) discovery: DiscoveryConfig,
}
Expand Down Expand Up @@ -973,6 +1056,79 @@ mod tests {
}
}

/// A client name alone configures nothing, so it is a usage error rather
/// than a flag silently ignored.
///
/// The first half is skipped when the URL variable is exported: clap reads
/// the environment itself, has no per-parse override, and `set_var` is
/// unsafe and racy across tests, so the flag would be satisfied by it.
#[test]
fn the_telemetry_client_name_needs_a_clickhouse_url() {
if std::env::var_os("ETHLAMBDA_TELEMETRY_CLICKHOUSE_URL").is_none() {
let mut args = beacon_args();
args.extend(["--telemetry.client-name", "x"]);
let err = try_parse_from(args).expect_err("a client name needs a URL");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}

let mut args = beacon_args();
args.extend([
"--telemetry.clickhouse-url",
"http://u:p@127.0.0.1:8123/?database=default",
"--telemetry.client-name",
"x",
]);
let beacon = parse_beacon(args);
let url = beacon
.common
.telemetry_clickhouse_url
.expect("the URL parses")
.into_inner();
assert_eq!(url.username(), "u");
assert_eq!(beacon.common.telemetry_client_name.as_deref(), Some("x"));
}

/// `reqwest::Url`'s `Debug` prints the password, and the options derive
/// `Debug`.
#[test]
fn the_clickhouse_url_keeps_its_credentials_out_of_debug() {
let mut args = beacon_args();
args.extend([
"--telemetry.clickhouse-url",
"http://user:hunter2@clickhouse.example:8123/?database=secret_db",
]);
let beacon = parse_beacon(args);
let debug = format!("{beacon:?}");
for secret in ["hunter2", "user:", "secret_db"] {
assert!(!debug.contains(secret), "Debug leaked {secret}: {debug}");
}
assert!(debug.contains("clickhouse.example"));
}

/// Only the HTTP interface is spoken, so another scheme is a startup
/// error rather than endless insert failures, and the message does not
/// echo the value, which may hold a password.
#[test]
fn the_clickhouse_url_must_be_http_and_errors_do_not_echo_it() {
for raw in [
"tcp://user:hunter2@127.0.0.1:9000",
"http://user:hunter2@host:notaport",
] {
let mut args = beacon_args();
args.extend(["--telemetry.clickhouse-url", raw]);
let err = try_parse_from(args).expect_err("must be refused");
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
let shown = err.to_string();
assert!(
!shown.contains("hunter2"),
"error echoed the value: {shown}"
);
}
let mut args = beacon_args();
args.extend(["--telemetry.clickhouse-url", "https://127.0.0.1:8443"]);
parse_beacon(args);
}

/// The invariant `From<BeaconOptions>`'s `unreachable!` rests on. Each
/// flag `requires` the other, so a half-configured Engine API is a usage
/// error out of the parser rather than a panic several frames later.
Expand Down
Loading
Loading