Repository navigation
feat: write per-block telemetry rows to ClickHouse in Silver's block_events layout - #765
MegaRedHand wants to merge 21 commits into
Conversation
ClickHouse-side per-block analysis needs wall-clock rows keyed by slot and root; the import report only has monotonic instants. The anchor converts them at encoding time, and the slot offset is left NULL outside the live window so catch-up imports do not read as late ones.
One insert per tick unless a full request is waiting, since ClickHouse writes a part per insert; rows read off the channel while a request is in flight; a bounded backlog that keeps failed rows for the next tick. A slow or unreachable ClickHouse costs bounded memory and never blocks an emitter.
Emit sites live in two actors and a blocking task; a global keeps their spawn signatures unchanged, and building rows lazily keeps the disabled path free.
The first insert must succeed so the flag is already cleared when the failing one runs.
…t forever A 400 on an insert means the rows themselves are malformed, so requeueing them blocked every later insert until they aged out of the backlog. Such a batch is now counted as dropped and logged with ClickHouse's message; every other failure still retries. Also: a slot start past what DateTime holds is NULL (ClickHouse wraps it silently); TelemetryConfig no longer derives Debug (it would print URL credentials); failure messages carry the error's source chain and a bounded response body; the schema statement is best effort so an insert-only user still inserts; pending rows are counted as dropped when the channel closes; a client build failure disables telemetry instead of panicking. Tests cover the rejected batch, immediate follow-up sends, basic auth, preserved query parameters, a failing schema statement, and a no-op emit when not installed.
…ptions A table whose column types differ from our rows makes every batch a 400, which would log a warning every second; only the first of a streak warns. The module doc, the schema-flag comment and the failure counter's help text now describe the refusal path, and a schema error identical to the insert's is no longer repeated in the message.
The import report already holds every boundary a block crosses; this turns its final pass into rows named like Silver's stages where the meaning matches, so the two clients' timelines can be compared from one table.
Silver's attestable means the new head is published, and beacon recomputes the head once per arrival, after the cascade, so every block the cascade imported becomes attestable then. Arrival rows are filed under the arriving block only when it was imported here: a re-delivery of a stored block reports Imported from an early return that ran nothing.
…lumns The availability check is timed for every block inside the DA window, including gloas blocks (their data gates the envelope) and fulu blocks with no commitments, so naming its end da_available claimed data was made available for blocks with nothing to make available. Record whether the check required columns and name the end da_check_end otherwise. Also pin that the arrival is a cascade's first block, share one source label between blocks and arrivals, and correct two comments that overstated what the final pass carries and what a test covers.
…ag through the actor The arrival record was only tested with a hand-built cascade, which did not pin that it is reset once per on_block rather than per block. Drive it through on_block with a stored ancestor queued behind the arriving block, and pin where da_columns_required is set (only for a check that found columns to require). Reword a comment that said "waited on columns" for a flag that is also true when the columns were already there.
Per-column arrival and validation times are what separate a slow data availability wait from a slow import; Silver's dashboard reads the first column_recv against da_available. Written after the check, as Silver records only validated columns, so the duplicates overlapping range answers deliver write nothing.
…he re-check A fulu sidecar's block root is a header hash, so the rows now compute it only when a row is built, and once. Whether a sidecar arrived is a property of ColumnArrival rather than an Option beside a source label, and the docs say what the rows actually dedup: only an already stored re-delivery writes none, so a reader takes the earliest row per column and stage.
A column's row fields were named in two places and a kept fulu column hashed its header twice. One private builder now names them, and the arrival row is derived from the validation row.
Both sub-commands take it. The default client name differs from the bare hostname because another client on the same host (Silver on eth-5) already writes the table under it.
reqwest::Url's Debug prints the password and the options derive Debug; clap also echoes a rejected value. A newtype with a host-only Debug and a parser that builds its own error close both, and refuse non-HTTP schemes at startup. The name defaults move into a pure function so the hostname is read only when telemetry is on and needs it.
…n its error The scheme check refused the old "unparseable" case, so the parse branch was never exercised. The error now also names the environment variable, since someone who set only that has no flag to look for.
…eeded update_head_from_fork_choice marks head_end on its error arm too, so every block a cascade imported was reported attestable at a head computation that failed and moved nothing. Carry whether the computation succeeded and write the attestable rows only then; the get_head rows stay either way, since the time was spent.
🤖 Codex Code ReviewAutomated review by OpenAI Codex |
🤖 Claude Code ReviewReview: PR 765, ClickHouse block telemetryI read the full diff, What looks good
Minor points, none blocking
The unchecked test-plan item (pilot against a real ClickHouse and comparison with Silver's rows) is the main remaining validation. The writer is fully tested against a mock server, so I'd treat that pilot as a post-merge check. Verdict: approve, with the optional cleartext-credentials warning in point 2 as the most useful follow-up. Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Kimi Code ReviewI'll review this PR for the telemetry system integration. Let me analyze the code across correctness, security, performance, and Rust best practices. SecurityCredential Exposure in
|
| Severity | Count | Description |
|---|---|---|
| Critical | 0 | No consensus or security critical issues |
| High | 0 | No memory safety or DoS vectors |
| Medium | 2 | Anchor per-event jitter; truncate underflow |
| Low | 4 | Debug formatting; hostname comment; auth error handling; typo |
The code is well-structured, security-conscious with credential redaction, and thoroughly tested. The telemetry system correctly avoids impacting performance when disabled and handles backpressure gracefully.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
…events layout (#765) Opt-in per-block telemetry: with --telemetry.clickhouse-url (or ETHLAMBDA_TELEMETRY_CLICKHOUSE_URL), a node writes one ClickHouse row per point in each block's life, and per data column arrival, in Silver's block_events table layout, so both clients' timings can be compared block by block from one table. Off by default: without the flag, emit builds nothing. See docs/telemetry.md. Cherry-picked from feat/clickhouse-block-events at 119e70e. Adapted to three nightly-only changes the PR's base (main 4992d8d) lacks: - #755 sends engine_newPayload from a task while the state transition runs speculatively, so the `engine` pair is only the wait left afterwards. The el_sent and el_verdict rows now come from new engine_sent/engine_answered instants that bracket the request itself, and the speculative transition writes stf_spec_start/stf_spec_end. docs/telemetry.md describes the overlap. - #760 answers a block whose stored columns are complete with DataAvailability::ColumnsVerified, which also counts as columns required, so such a block writes da_available rather than da_check_end. - #754 checks a block's sidecars as one group with a batched KZG, so the column rows are written from GroupVerdicts::record, once each sidecar's verdict is known.
…ility exhaustively Two rows depended on facts the compiler could not keep true: - el_sent and el_verdict read the `engine` section pair, whose meaning is the time the import waited for the answer. That equals the request's own round trip only while nothing runs during it; an import that overlaps the state transition with the request (as nightly's #755 does) would turn both rows into the wait without any type changing. The answer now carries its own sent/answered instants and verdict (`EngineAnswer`). - da_available vs da_check_end came from `matches!(..., Columns(_))`, so a new kind of availability evidence silently counts as nothing required. Merging into nightly hit exactly that with #760's `ColumnsVerified`. An exhaustive match makes a new variant a compile error here. Also warn at startup when the ClickHouse URL sends credentials over plain http to a host that is not loopback (suggested in review).
…events layout (#765): review fixes fix(telemetry): give the el rows their own instants and match availability exhaustively - el_sent and el_verdict come from an `EngineAnswer` (sent, answered, verdict) instead of the `engine` section pair, which is the time the import waited and, with #755's overlap, not the request's round trip. - da_available vs da_check_end comes from an exhaustive match on the availability evidence, so a new variant is a compile error rather than a silent "nothing required". - A startup warning when the ClickHouse URL sends credentials over plain http to a host that is not loopback. Updates nightly's #765 from feat/clickhouse-block-events at 119e70e to f2eb961. On nightly the answer's instants are the speculative path's request (spawned) and answer (received) instants, which replaces the nightly-only engine_sent/engine_answered fields, and the exhaustive match also counts #760's ColumnsVerified as columns required.
…ient's blob pool (#769) A fulu block whose custody columns have not all arrived waits on gossip and on per-slot peer asks. The blob transactions it commits to usually passed through the local execution client's pool first, so the node can often build those columns itself, sooner. Fulu's p2p-interface asks honest nodes to do so with engine_getBlobsV2, as Lighthouse and the other clients already do. On a fulu block's first data-availability hold, with an execution client configured, the chain actor spawns a task that asks engine_getBlobsV2 once (1 s, the spec's timeout, outside the retry ladder: the call races gossip), builds the custody columns still missing without verifying the KZG proofs (the spec says to skip verification for local-EL blobs), and hands them to p2p through the existing check_data_column_sidecars, now tagged with a SidecarOrigin. For ExecutionLayer sidecars p2p runs no checks; it records them in the seen cache, publishes them on their subnets, and forwards them to the chain actor, which stores them and releases the block. Those two steps are the spec's MUSTs for importing local-EL blobs, and p2p owns both. V2 only: V3 answers partially, and a column needs a cell from every blob. Gloas is not covered, since availability moves to the payload envelope. On nightly, a Parked recheck keeps #765's ColumnArrival::Recheck, and the CLAUDE.md peer-scoring line keeps #739's slow-peer text. Columns built from the execution client write no block_events telemetry rows: #765's column rows cover gossip-accepted and fetched columns only. Cherry-picked from feat/engine-get-blobs-v2 at 39d5f07 (e0f69b3 plus a merge of main). Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
…rs (#773) The chain actor waited for every engine_newPayload inside the import, so the head moved only after EL + state transition and the actor sat idle for the EL round trip, handling nothing else. Gloas envelopes did the same. A pre-gloas block that is an optimistic candidate now imports as optimistic as soon as its consensus checks pass, and a gloas envelope applies with its payload NOT_VALIDATED. The newPayload question goes out first, from a tokio task that reports back with a NewPayloadAnswered message, and the answer is applied then as optimistic-sync.md applies a verdict. Optimism already gates attestation data, so validators still attest only to validated payloads. - Payloads are sent parent-before-child, at most 4 in flight, and forkchoiceUpdated names the newest payload on the head's chain whose request is not queued or in flight (walking the waiting payloads' parent hashes), sent again when the head's own answer arrives, so a catch-up still feeds the EL a head. - At most 128 payloads wait for an answer; past that the import is held until answers free room, so catch-up cannot run unboundedly ahead. - No answer after the retry ladder keeps the block optimistic and asks again each slot. optimistic-sync.md says such a block MUST NOT be applied; it already is, so this is documented as a deviation. - An envelope answered INVALID with a null latestValidHash (how Engine API V2+ reports a block hash mismatch) is taken back out and only that copy refused, matching Lighthouse and Prysm. Blocks built on a FULL payload wait for its answer, so nothing is built on an envelope that gets taken back out. - A VALID answer for the current head sends `head` again without the optimistic flag, for validator clients that react to head events. Non-candidate blocks (merge transition) and servers without a mailbox (offline replay, the import benchmark) still wait for the answer inline. On nightly, that inline wait is #755's: the request goes out from a task and the block's state transition runs meanwhile, so the inline path keeps its overlap, speculation metrics and EngineAnswer telemetry rows, and #773's own inline helper (ask_payload_inline) is dropped. The engine section of an import that asks without waiting is a recorded zero. An applied envelope still publishes #748's execution_payload and execution_payload_available events, now before its answer. Answers that arrive later write no el_sent/el_verdict telemetry rows (#765): those come only from an import that waited. Cherry-picked from perf/async-new-payload at 33d1969 (f23010e, 33d1969). Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
…rs (#773): write the engine rows and attestable when the answer is applied fix(telemetry): time a block's attestable and engine rows by its answer With #773 a block that may be imported optimistically no longer waits for engine_newPayload, so #765's block_events rows lost the execution client: el_sent and el_verdict came only from an import that waited (none on the live node since), and attestable, written at the head recomputation after the cascade, now marked a head the block was optimistic in, which validators cannot attest to. On beacon-eth4 that cut received-to-attestable from a 75 ms median to 40 ms without any block becoming attestable sooner, so the Silver comparison read faster than it was. - Each pre-gloas ask records the import's source and when its request went out; the request task stamps when its answer came back. - Applying the answer writes el_sent and el_verdict at those instants, and attestable at the moment a VALID (or NOT_REQUIRED) answer marks the block validated. The head recomputation leaves out the attestable of a block whose answer is still out. The answer is handled only after the import that asked, head recomputation and forkchoiceUpdated included, so the applied moment is always the later one; attestable - el_verdict is the time the answer waited in the mailbox. - No answer writes nothing (the redrive's answer does, with its own el_sent); an answer for a block that left fork choice writes nothing; a SYNCING/ACCEPTED or INVALID answer writes el_sent and el_verdict only. Envelopes write no block rows, as before. docs/telemetry.md says when each row is written on the asynchronous path. Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB
Summary
Opt-in per-block telemetry: with
--telemetry.clickhouse-url(orETHLAMBDA_TELEMETRY_CLICKHOUSE_URL), a node writes one ClickHouse row per point in each block's life, and per data column arrival, in theblock_eventstable layout Silver uses. Both clients' timings can then be compared from one table and one set of queries, block by block, which thelean_block_import_phase_secondshistogram cannot do (it aggregates the blocks away).Off by default: without the flag,
emitbuilds nothing.install/emitsinkcrates/common/telemetryattestableafter the per-arrival head recomputationcrates/blockchain(import_timing.rs,lib.rs)column_recv/column_validatedrowscrates/net/p2p(beacon/column_checks.rsand callers)P2P::spawnbin/ethlambdadocs/telemetry.md(new),docs/cli.md,docs/metrics.md,CLAUDE.mdDesign choices
received,da_available,el_sent/el_verdict,stf_done,attestable),<section>_start/_endfor ethlambda's other import sections.sourceandverdictkeep ethlambda's own values.try_sendinto a bounded channel; a tokio task batches and POSTsJSONEachRowto ClickHouse's HTTP interface withreqwest(already a dependency). One insert per tick (1 s) unless a full request is waiting, since ClickHouse writes a part per insert.attestablemeans what Silver's does: the new head including the block is computed. Beacon recomputes the head once per arrival, after the cascade, so every block the cascade imported gets that time (only if the head computation succeeded).el_sent/el_verdictcarry their own instants (EngineAnswer: sent, answered, verdict), not the import'senginesection, which is the time the import waited. The two only coincide while nothing else runs during the request; an import that overlaps the state transition withengine_newPayloadkeeps correct rows.da_availablecomes from an exhaustive match on the availability evidence, so a new kind of evidence is a compile error here rather than a silent "nothing required".Debugshows scheme, host and port only, a rejected value is never echoed in the CLI error, and request errors drop the URL. A startup warning fires when the URL sends credentials over plain http to a host that is not loopback.Facts checked against a real ClickHouse 25.8 over HTTP: a malformed or wrongly typed row is a 400, an unknown table a 404, bad credentials a 403; an integer of nanoseconds parses into
DateTime64(9); aDateTimeaboveu32::MAXseconds is silently wrapped, so the encoder writes NULL there.Known gaps (also in
docs/telemetry.md)gossipwith near-zero arrival latency; filter them by their missingdecode_endrow. A dedicated source is a possible follow-up.el_*rows (the payload goes to the execution client with the envelope).Pilot (mainnet, ethlambda-4)
A mainnet follower on
nightly(this PR plus the open perf stack) has written to a local ClickHouse 25.8 since 2026-11-03 18:38Z, with--telemetry.client-name beacon-eth4. The node's own DDL created the table; inserts run at one per second with 0 failures and 0 dropped rows.received,stf_done,el_verdictandattestablecolumn_validatedhas itscolumn_recv(8 custody columns per block with blobs)slot_start_date_timemismatchesreceivedtoattestableMerging into
nightlysurfaced the two robustness issues fixed in f2eb961: nightly's #755 overlapsengine_newPayloadwith the state transition (theel_*rows had been read from the wait section), and #760 adds aColumnsVerifiedevidence variant that the earliermatches!counted as nothing required.Test plan
make fmt,make lintcargo test -p ethlambda-telemetry --profile release-fast(19, including a mock ClickHouse: batching, retry, cap, refusal, basic auth, slow requests)cargo test -p ethlambda-blockchain --lib --profile release-fast(310)cargo test -p ethlambda-p2p --lib --profile release-fast(365)cargo test -p ethlambda --bins --profile release-fast(272)