Skip to content

feat(tooling): add event-monitor arrival-time dashboard - #538

Merged
MegaRedHand merged 12 commits into
mainfrom
feat/event-monitor
Jul 29, 2026
Merged

feat(tooling): add event-monitor arrival-time dashboard#538
MegaRedHand merged 12 commits into
mainfrom
feat/event-monitor

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds tooling/event-monitor/, a standalone live arrival-time dashboard for
lean-consensus (ethlambda) nodes. It dials the GET /lean/v0/events SSE stream
of several nodes, timestamps each event on a single collector clock, and serves
a browser dashboard visualizing:

  • a rolling beeswarm of each event's arrival offset within the slot, one lane per node, and
  • a propagation-delta beeswarm: for a given block / aggregate, how long after the first node each other node saw it.

The rolling window is adjustable live from the header, and a fresh page load
backfills recent history from the collector so it is never blank.

Design

  • Standalone Cargo workspace under tooling/event-monitor/ (empty [workspace] table); it is not a member of the parent ethlambda workspace and does not affect the main build.
  • Zero dependency on any ethlambda crate — it only speaks the documented SSE/HTTP wire shape. CONTRACT.md is the frozen interface between the Rust/axum backend (src/) and the vanilla-JS frontend (web/, no build step).
  • A single collector clock makes propagation deltas skew-free across nodes.
  • ?demo=1 runs the frontend fully offline with synthetic data.

This PR contains the tooling plus the CI wiring needed to actually check it. The
RPC / event-bus changes that make a node emit these events shipped separately in
the chain-events series (below).

Required ethlambda API functionality

All dependencies are satisfied on main — the default dashboard view works
against a current node with no extra PRs.

Capability Endpoint / topic(s) Status
SSE events stream GET /lean/v0/events #517
Topic filtering ?topics=<csv> #518
Slot-geometry bootstrap GET /lean/v0/genesis, GET /lean/v0/config/spec
Base topics block, head, justified_checkpoint, finalized_checkpoint #516
Attestation / aggregate topics attestation, aggregate #534
Block-gossip topic block_gossip #535

The collector accepts block, block_gossip, head, justified_checkpoint,
finalized_checkpoint, attestation and aggregate; any other topic is logged
and skipped. safe_target / chain_reorg (#533, closed) were removed in
4eeafff — both panels' topic filters discarded them, and chain_reorg is a
point-in-time event rather than a per-node arrival race, so it does not belong on
a beeswarm.

Both panels default to block / attestation / aggregate.

Review fixes

Three defects found in review, each with a regression test:

  • Reconnect backoff never reset after a healthy session. The reset was keyed on a clean end-of-stream, but a node restart surfaces as a stream error, so every restart ratcheted the delay one step permanently; after ~7 of them a healthy node reported down with 10s reconnects. Now keyed on session duration (survived ≥1 heartbeat). This also closes the inverse case: a peer that accepts the request and instantly closes the stream used to be retried at INITIAL_BACKOFF forever.
  • One bogus slot blanked the dashboard. Both the history ring and the rolling window key retention off the highest slot seen, and that watermark only moves up, so a single event from a node on a different genesis aged out every real event until a restart. Events more than MAX_FUTURE_SLOTS ahead of the collector's own slot are now dropped, warning once per node per connection. Only the future side is bounded; old slots are legitimate (finalized_checkpoint trails head) and cannot move the watermark.
  • Stale status clobbered fresh status. The frontend applies status immediately but buffers chain during backfill, so the /api/history snapshot could overwrite a newer live status. Live status now wins.

Also from that review:

  • Slot geometry is re-resolved every 60s and republished when it changes, dropping the retained history and slot watermark, so a regenerated genesis no longer silently corrupts every subsequent offset_ms. Collectors re-read per frame and /api/meta per request; an already-open tab needs a reload to pick up new geometry.
  • The arrival axis now spans two slots at two scales: the first at full resolution (90.9% of the width at 5 intervals), the next compressed into a tinted band half a first-slot interval wide, saturating beyond that. A block spilling past its slot boundary is the failure mode worth seeing, and it used to be indistinguishable from one landing exactly on the boundary.
  • offset_ms includes the collector↔node round trip. One clock is what makes propagation deltas skew-free, but nodes reached over different links carry a systematic offset that reads as lag. Now documented in README.md and CONTRACT.md §2.
  • History events are Arc-shared, so publish_chain no longer deep-copies per event and /api/history no longer clones up to HISTORY_MAX_EVENTS events while holding the mutex.
  • topics = [] / nodes = [] are rejected at load rather than becoming an opaque retry loop against a 400.

Known and deliberate, left as follow-ups: the collector cannot see upstream
: error - dropped N messages comments (eventsource-stream swallows them), so
a lagging subscription silently under-samples; both canvases still repaint
unconditionally at 60fps; and bootstrap has no retry, so the monitor exits if
started before its nodes are listening.

CI

tooling/event-monitor declares its own [workspace], so cargo fmt --all,
cargo check --workspace, cargo clippy --workspace, make lint and make test
all stop at the root workspace members and never reached it — it landed with
nothing verifying it. The Lint job now also runs cargo fmt --check,
cargo clippy --all-targets -- -D warnings and cargo test under
working-directory: tooling/event-monitor, and rust-cache lists it as a second
workspace so its target dir is cached and its Cargo.lock feeds the cache key.

Testing

  • cargo test in tooling/event-monitor/: 41 passed (39 lib + 2 integration), 0 failed.
  • cargo fmt --check and cargo clippy --all-targets -- -D warnings: clean.
  • ?demo=1 offline synthetic mode renders both panels with no live nodes.
  • Verified end-to-end against a live local devnet (blocks land ~0.1–0.2 s into the slot, attestations ~0.8 s = interval 1).
  • Two-slot axis geometry checked numerically: the overflow band is exactly 0.5 × one first-slot interval, the mapping is monotonic, and the endpoints land on the margins.
  • Geometry refresh checked against a stub node: rewriting its genesis_time mid-run is picked up within one refresh interval (WARN slot geometry changed), /api/meta then serves the new epoch, and the history ring holds the restarted chain's low slots with in-slot offsets — which only works because the watermark is reset, otherwise they would prune as older than the previous high-water mark.

Run

cd tooling/event-monitor
cp config.example.toml config.toml
$EDITOR config.toml                 # list your nodes' RPC URLs + topics
cargo run --release -- --config config.toml
# open the `listen` address (default http://127.0.0.1:8080)

Standalone Rust/axum collector + vanilla-JS dashboard that dials the /lean/v0/events SSE stream of several ethlambda nodes, timestamps each event on one collector clock, and visualizes arrival offset within the slot (rolling per-node beeswarm) and inter-node propagation delay per block/aggregate/head (a second delta beeswarm, delay behind the first node to see each id).

Own Cargo workspace with no ethlambda-crate deps; speaks only the documented SSE/HTTP wire shape frozen in CONTRACT.md. Rolling window is adjustable live from the header, a fresh page load backfills recent history via GET /api/history so it is never blank, and ?demo=1 runs the frontend fully offline on synthetic data.
@MegaRedHand
MegaRedHand marked this pull request as draft July 22, 2026 17:34
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall: This is a well-architected standalone monitoring tool with clear separation of concerns, good documentation (CONTRACT.md), and robust error handling. The code is idiomatic Rust and the frontend uses efficient canvas rendering. Only one critical issue blocks compilation.


1. Critical: Invalid dependency version

File: tooling/event-monitor/Cargo.toml
Line: 25

toml = "1.1.3"

Issue: The toml crate has no version 1.1.3 (latest is 0.8.19). This will cause cargo to fail resolving the dependency.

Fix:

toml = "0.8"

2. Code correctness & robustness

File: tooling/event-monitor/src/collector.rs
Line: ~166 (inside connect_and_stream)

let url = format!(
    "{}/lean/v0/events?topics={}",
    node.url.trim_end_matches('/'),
    topics.join(",")
);

Issue: Topics are not URL-encoded. If a topic name contains reserved characters (e.g., &, =, %), the query string will be malformed.

Fix: Use reqwest::Url or percent-encode:

let url = format!("{}/lean/v0/events", node.url.trim_end_matches('/'));
let url = reqwest::Url::parse_with_params(&url, &[("topics", topics.join(","))])?;

File: tooling/event-monitor/src/main.rs
Line: ~69

axum::serve(listener, app).await?;

Issue: No graceful shutdown handling. The server will not respond to SIGINT/SIGTERM cleanly, potentially dropping active SSE connections.

Fix: Add signal handling:

let server = axum::serve(listener, app);
let shutdown = tokio::signal::ctrl_c();
tokio::select! {
    result = server => result?,
    _ = shutdown => tracing::info!("shutting down"),
}

File: tooling/event-monitor/src/config.rs
Line: ~42 (Config::load)

Issue: No validation that nodes is non-empty or that URLs are valid. An empty node list results in a useless process; invalid URLs will fail later at runtime.

Fix: Add validation after loading:

if config.nodes.is_empty() {
    return Err(anyhow::anyhow!("config must specify at least one node"));
}

File: tooling/event-monitor/src/server.rs
Line: ~56

let serve_dir = ServeDir::new(static_dir).fallback(ServeFile::new(index_html));

Issue: static_dir comes from user config without path sanitization. While ServeDir is generally safe, ensure static_dir is resolved to an absolute path to avoid ambiguity.

Fix: Resolve to absolute path:

let static_dir = std::fs::canonicalize(static_dir)?;

3. Security considerations

File: tooling/event-monitor/src/server.rs
Issue: No CORS configuration. If the dashboard is accessed from a different origin (e.g., during development or if behind a reverse proxy), API calls will fail.

Fix: Add tower_http::cors::CorsLayer (optional but recommended):

use tower_http::cors::CorsLayer;
// ...
Router::new()
    .layer(CorsLayer::permissive()) // or restricted to specific origins

4. Performance & correctness observations

File: tooling/event-monitor/src/model.rs
Line: ~182 (aggregate_id)

let canonical = serde_json::to_string(&input).expect("aggregate id input is always valid JSON");

Observation: The expect is safe here since the input is a controlled struct with no floats or maps, but document this invariant.


File: tooling/event-monitor/src/collector.rs
Line: ~236 (now_ms)

i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)

Good: Correctly handles the theoretical overflow case.


File: tooling/event-monitor/src/hub.rs
Line: 20

const HISTORY_MAX_EVENTS: usize = 50_000;

Good: Hard cap prevents unbounded memory growth during attestation floods.


File: tooling/event-monitor/web/app.js
Line: ~43 (applyStatus)

rate.textContent = `${status.events_per_sec.toFixed(1)}/s`;

Issue: If the backend sends NaN or Infinity (possible if the rate tracker window is zero), this displays poorly.

Fix: Add a guard:

if (Number.isFinite(status.events_per_sec)) {
    rate.textContent = `${status.events_per_sec.toFixed(1)}/s`;
} else {
    rate.textContent = "—";
}

5. Testing

File: tooling/event-monitor/tests/sse_integration.rs

Good: Integration test with a fake SSE server is well-written and covers the normalization pipeline end-to-end.

Suggestion: Add a test for the pruning logic in hub.rs to verify the HISTORY_MAX_EVENTS cap is respected under load.


Summary of required changes

  1. Fix Cargo.toml toml version to 0.8.
  2. Fix URL construction in collector.rs to encode query parameters.
  3. Add graceful shutdown handling in main.rs.
  4. Validate config has at least one node.

The rest are suggestions for hardening. The architecture is sound and the separation between the Rust collector and JS frontend via CONTRACT.md is excellent practice.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. tooling/event-monitor/src/collector.rs: the SSE dial path has no connect/header timeout. client.get(&url).send().await can block indefinitely on a blackholed or half-open node, which means that collector task never reaches the retry/backoff path and may sit in reconnecting forever instead of transitioning to down. For a long-lived SSE stream you still want an initial connect timeout, even if you do not want a read timeout after the stream is established.

  2. tooling/event-monitor/src/collector.rs: a clean EOF resets the backoff (Ok(()) => backoff.reset()), then the loop sleeps only the initial 250ms again. If an upstream/proxy closes the SSE stream immediately or periodically, this becomes a tight reconnect loop that never ramps to MAX_BACKOFF and never reports down, which is both noisy and needlessly aggressive toward the node. EOF should be treated as a failed attempt unless you intentionally want “server closed stream” to count as healthy.

  3. tooling/event-monitor/src/hub.rs: history pruning only removes stale events from the front of the deque. That assumes near-monotonic slot order, but collectors across nodes/topics can deliver late older events. If a stale slot arrives after newer ones, it gets appended to the back and survives in /api/history past history_slots, so a newly opened dashboard can be seeded with out-of-window data. This needs a full scan/retain, or storage ordered by slot rather than arrival.

No consensus-path code is touched here, so fork choice, attestation validation, STF, XMSS, and SSZ correctness are not directly affected by this PR.

I couldn’t run cargo test in this sandbox because rustup tried to write under a read-only home directory, so the review is from static inspection. Aside from the points above, the normalization logic and the overall separation between collector, hub, and server are straightforward and maintainable.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 538 — tooling/event-monitor

This is a standalone, well-isolated tool (own Cargo workspace, zero deps on ethlambda crates) with a frozen CONTRACT.md interface, strong test coverage (29 tests spanning unit + a real in-process SSE integration test), and careful defensive coding. Overall quality is high; findings below are minor.

Strengths worth calling out:

  • No panics on the hot path: unknown topics/malformed JSON are Err-mapped and logged, never unwrap()'d; the one genuine expect() (serializing an internal struct with no maps/floats) is justified with a comment explaining why it's infallible.
  • hub.rs's history ring bounds memory two ways (slot-age prune + hard HISTORY_MAX_EVENTS cap) — good protection against an attestation flood before the slot-based prune catches up.
  • Frontend (app.js, beeswarm.js, propagation.js) consistently uses textContent/createElement instead of innerHTML for anything derived from server data — no XSS surface despite rendering arbitrary node names/topics from the wire.
  • Static file serving uses tower_http::ServeDir/ServeFile, which handles path traversal correctly rather than hand-rolling path joins.
  • aggregate_id deliberately serializes a typed struct (not an arbitrary serde_json::Value) to get deterministic field ordering for the content hash — a subtle correctness point that's called out in the comment.
  • The startup backfill logic in app.js (buffer live events while /api/history loads, then flush + de-dup by composite key) correctly avoids a gap between "stream opened" and "history fetched" without double-counting.
  • NormalizedEvent mapping in model.rs matches CONTRACT.md §3 exactly for every topic variant, including the aggregate id derivation and the attestation → id: null rule.

Minor items (reported via findings tool):

  1. timing::bootstrap always does a network round-trip to fetch genesis/spec even when the config fully overrides both genesis_time and ms_per_slot — wastes up to FETCH_TIMEOUT per configured node on every offline/fully-overridden startup.
  2. The SSE URL in collector::connect_and_stream is built via format! without percent-encoding node.url/topics — low risk since these come from trusted local config, not attacker input, but a malformed value (stray space/&) would silently produce a bad URL rather than fail config validation.

Nothing here rises to a correctness or security blocker — this reads as a mature, ready-to-merge dev-tooling addition.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a standalone dashboard for monitoring event arrival and propagation times. The main changes are:

  • Rust collectors for multiple node SSE streams.
  • Bounded history backfill and live status updates.
  • Browser beeswarm views for slot offsets and propagation delays.
  • Configuration, documentation, tests, and an offline demo mode.

Confidence Score: 5/5

No additional blocking issue qualifies for a new follow-up comment.

  • The remaining configuration failure is already covered by the existing feedback.
  • The out-of-order history behavior is part of the existing history-pruning issue.
  • No separate production failure requiring an independent change was found.

Important Files Changed

Filename Overview
tooling/event-monitor/src/config.rs Defines monitor settings, node endpoints, and default event topics.
tooling/event-monitor/src/hub.rs Adds live event broadcasting, node status storage, and bounded history backfill.
tooling/event-monitor/src/collector.rs Connects to node event streams and publishes normalized events and status updates.
tooling/event-monitor/web/app.js Bootstraps metadata and history, merges live events, and updates the dashboard.

Reviews (2): Last reviewed commit: "Merge branch 'main' into feat/event-moni..." | Re-trigger Greptile

Comment on lines +57 to +63
fn default_topics() -> Vec<String> {
vec![
"block".to_string(),
"attestation".to_string(),
"aggregate".to_string(),
]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Default Subscription Is Rejected

The current event endpoint rejects the entire request when any topic is unknown. With these defaults, nodes on main return HTTP 400 for attestation and aggregate, so the collector also loses supported block events and remains reconnecting/down. The default must use currently supported topics or the collector must retry with unsupported topics removed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tooling/event-monitor/src/config.rs
Line: 57-63

Comment:
**Default Subscription Is Rejected**

The current event endpoint rejects the entire request when any topic is unknown. With these defaults, nodes on `main` return HTTP 400 for `attestation` and `aggregate`, so the collector also loses supported `block` events and remains reconnecting/down. The default must use currently supported topics or the collector must retry with unsupported topics removed.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread tooling/event-monitor/src/hub.rs
Quality cleanups from a /simplify pass; no behavior change and the
serialized wire shape is untouched:

- serialize HistorySnapshot directly, dropping the field-identical
  HistoryResponse DTO and its hand copy
- add NodeConfig::endpoint() as the single home for the node URL join,
  used by the collector and the timing bootstrap
- fold the checkpoint-topic payload into SlotBlockPayload (serde ignores
  the extra state field) and trim ReorgPayload to the surfaced fields
- extract node_status() for the four repeated status literals
- fetch genesis + spec concurrently so a dead node costs one timeout
- propagation panel: reject unknown nodes at ingest, drop a dead branch,
  and cache per-dot jitter + the id's min arrival at insert rather than
  recomputing both every animation frame
The propagation panel offered a `head` toggle, but head arrival spread is a
fork-choice output derived from the same attestation set as `block`, so it
told us nothing the block lane didn't already show. Dropping the toggle also
lets the collector stop subscribing to a topic no panel renders.

`safe_target` and `chain_reorg` were normalized backend-side and then
discarded by both panels' topic filters, so they only ever cost a match arm
and a payload struct. `chain_reorg` in particular is a point-in-time event
rather than a per-node arrival race, so it does not belong on a beeswarm.

Keeps CONTRACT.md authoritative: the §2 topic table and the propagation
toggle spec now match what the code accepts and renders.
@MegaRedHand
MegaRedHand marked this pull request as ready for review July 24, 2026 20:53
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. ms_per_slot/intervals_per_slot are never validated, so a bad spec response or config typo can wedge the dashboard. bootstrap() accepts zero values from /lean/v0/config/spec or overrides unchanged, and both canvases later do const step = msPerSlot / intervals; for (let ms = 0; ...; ms += step) in beeswarm.js and propagation.js. ms_per_slot = 0 makes step == 0, so the render loop never advances and the tab hangs. Reject zero/invalid timing inputs at config/bootstrap time.

  2. Static-file fallback is too broad and turns missing assets/API typos into 200 index.html responses. build_router() uses ServeDir::fallback(ServeFile::new(index_html)), so /app.jss, /favicon.ico, or even an unhandled /api/... path will return HTML instead of a 404. That makes broken asset paths fail opaquely in the browser and hides server-side routing mistakes. Route / explicitly to index.html and let other unknown paths 404.

  3. Startup backfill can temporarily roll node status backward. In app.js, live status events are applied immediately, but during backfill the later history.status.forEach(...) can overwrite a newer live status with an older snapshot. The chip self-heals on the next heartbeat, but the UI can show stale connected/reconnecting/down state or an older rate for several seconds. Buffer/de-dup status during backfill too, or add a monotonic timestamp/sequence.

No consensus-layer logic is touched in this PR, so fork choice, attestation validation, STF, XMSS, and SSZ correctness are unaffected here.

I couldn’t run cargo test in this sandbox: the standalone crate needs crates.io access and the environment has network disabled with no cached dependencies.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

I have findings to report, but let me give the full written review directly since the task calls for markdown output, not the structured tool.

Review: feat(tooling): add event-monitor arrival-time dashboard (PR #538)

Overall this is a well-scoped, cleanly isolated addition. It's a standalone Cargo workspace with zero ethlambda-* dependencies, a frozen CONTRACT.md interface, solid unit/integration test coverage (backoff, rate tracking, normalization edge cases, history pruning, SSE integration against a real in-process server), and the "never panic on unparsable input" invariant is followed consistently in model::normalize and collector::handle_frame. Since this doesn't touch consensus logic, fork choice, or the STF, the security/correctness bar is lower than for core crates, but a few things are worth tightening:

Minor

  1. Timing::offset_ms can overflow on adversarial/corrupted slot values (src/timing.rs:41)
    slot as i64 * self.ms_per_slot as i64 is an unchecked multiplication over a slot value that comes straight from an upstream node's SSE payload (model.rs deserializes it as a bare u64, no bounds check). A node that's buggy, malicious, or just sends a stray huge slot (e.g. near u64::MAX) can make this multiplication overflow. In cargo run/cargo test (debug profile) that's a panic; in --release it silently wraps to a bogus offset. Given the codebase's stated philosophy elsewhere ("never panics: an unknown topic or payload we can't parse is logged and dropped") it'd be consistent to use saturating_mul/checked_mul here too, since this is the one place raw slot reaches arithmetic rather than just being echoed back.

  2. No timeout on the initial SSE connect request (src/collector.rs:152)
    client.get(&url).send().await? has no per-request timeout (unlike fetch_json in timing.rs, which sets FETCH_TIMEOUT). If a node accepts the TCP connection but never sends response headers, that collector task blocks forever on send(), and the node's status stays stuck at Reconnecting indefinitely rather than ever settling to Down — even though the backoff/status contract (CONTRACT.md §4) implies a bounded transition. Doesn't affect other nodes (each runs as its own spawned task), but worth adding a connect/response timeout for consistency with the rest of the retry/status machinery.

Nits (no action needed, just flagging for awareness)

  • History::prune() (src/hub.rs:75) assumes events arrive at the front of the deque in roughly increasing slot order to bound memory by slot age; an event for a stale slot appended out of order at the back would only be evicted via the hard HISTORY_MAX_EVENTS cap, not the slot-age prune. The code comment already calls this out explicitly as an accepted approximation, so this is just confirming that tradeoff is understood, not a new finding.
  • aggregate_id's FNV-1a-64 hash is explicitly documented as session-stable/non-cryptographic and used only for UI grouping — appropriate for the use case, no concern.

Things done well

  • CONTRACT.md is genuinely kept authoritative — I checked the §2 topic table, §3 NormalizedEvent shape, and §6 propagation-toggle spec against the actual Rust/JS code and they match, including the most recent commit that dropped head/safe_target/chain_reorg from the propagation panel.
  • Reconnect backoff, rate tracking, and history pruning all have direct unit tests, not just integration smoke tests.
  • bootstrap() fetching genesis + spec concurrently via tokio::join! (timing.rs:94) is a nice touch so a dead node only costs one timeout instead of two serial ones.
  • Frontend correctly avoids innerHTML injection risk for node-supplied strings — uses textContent for user/node-derived text throughout app.js/beeswarm.js/propagation.js.
  • Startup backfill ordering (open /stream first, buffer, then fetch /api/history, then de-dup and flush) is correctly reasoned to avoid both gaps and double-counting.

No blocking issues — the two minor points above are defensive-coding suggestions for a tool that ingests data from network-facing (if trusted) nodes, not correctness bugs in the current happy path.


Automated review by Claude (Anthropic) · sonnet · custom prompt

Reconnect backoff reset was keyed on a clean end-of-stream. The common case is
the opposite: a node restart surfaces as a stream error after hours of healthy
streaming, so every restart ratcheted the delay one step permanently and after
~7 of them a perfectly healthy node reported `down` and took MAX_BACKOFF to
reconnect. Key the reset on how long the session lasted instead, via an
`Attempt` that is healthy once it survives one heartbeat. That also closes the
inverse hazard the old rule left open: a peer which accepts the request and
immediately closes the stream ended "cleanly", hit the reset, and was retried
at INITIAL_BACKOFF forever instead of backing off.

Both the collector's history ring and the dashboard's rolling window key
retention off the highest slot seen, and that watermark only ever moves up. One
event from a node on a different genesis therefore aged out every real event and
blanked both panels until a restart. Drop events whose slot is more than
MAX_FUTURE_SLOTS ahead of the slot the collector's own clock is in, warning once
per node per connection so the misconfiguration is visible rather than silent.
Only the future side is bounded: old slots are legitimate and common
(finalized_checkpoint trails head, a syncing node replays history) and cannot
move the watermark. Placing the check in `normalize` covers the history ring and
both canvas panels from one spot, since no bogus event reaches the frontend.

The frontend applies `status` immediately but buffers `chain` during startup
backfill, so the /api/history snapshot could overwrite a fresher live status
with an older one. Live status now wins.

Test fixtures paired slot-128 and slot-12 payloads with sub-second arrival
times, i.e. events far in the future, which the new bound rejects. They now
place arrival inside a plausible slot via `arrival_for`, keeping every original
assertion; `block_topic_maps_id_to_block_root` asserts CONTRACT §2's worked
offset of 123ms rather than a negative offset, which
`offset_ms_can_be_negative_under_clock_skew` already covers.

CONTRACT.md gains the slot bound in §2 and the live-status-wins rule in §4.
Each tool under tooling/ declares its own [workspace] table, which is what keeps
it decoupled from the root workspace but also means every existing check misses
it: `cargo fmt --all`, `cargo check --workspace` and `cargo clippy --workspace`
all stop at the root workspace members, as do `make lint` and `make test`.
tooling/event-monitor therefore landed with nothing verifying it, and would have
stayed green only for as long as someone remembered to run cargo by hand in that
directory.

Add `tooling-lint` (fmt --check + clippy -D warnings) and `tooling-test`, both
looping over `tooling/*/Cargo.toml` so a second tool is picked up without
touching the Makefile. `set -e` inside the loop is load-bearing: without it a
failing clippy would be masked by the exit status of the next command in the
loop body. The `[ -e ]` guard keeps the recipes a clean no-op if the glob ever
matches nothing.

Wire them into `lint` and `test` rather than leaving them standalone, so the
documented pre-commit workflow cannot pass locally while CI fails, and extend
`fmt` to format tooling too, so `make fmt` does not leave work that
`tooling-lint` then rejects.

The CI Lint job calls the same two targets instead of restating the commands, and
its rust-cache now lists tooling/event-monitor: separate workspaces have their
own target dir and Cargo.lock, so without listing it the tooling build is
recompiled from scratch every run and its lockfile does not feed the cache key.
Tests run in Lint rather than in the Test job because clippy --all-targets has
already compiled them there, and because they need none of the Test job's
leanSpec fixtures.
Keeps the Makefile out of it: the tooling checks live entirely in the workflow,
so `make fmt` / `lint` / `test` go back to meaning exactly what they meant before
and nothing in the root build description knows about tooling/.

The two steps now run cargo directly under `working-directory:
tooling/event-monitor`. Failure propagation still holds without the explicit
`set -e` the Makefile recipes needed, because the runner's default shell for a
multi-line `run` is `bash -e`, so a failing `cargo fmt --check` aborts the step
before clippy runs (verified: fmt violation exits 1, clippy violation exits 101).

The rust-cache `workspaces` entry stays, since caching a separate workspace's
target dir and folding its Cargo.lock into the cache key is a workflow concern
either way.

Trade-off this accepts: `make lint` and `make test` no longer cover tooling, so
they can pass locally while CI fails on it. Running cargo inside
tooling/event-monitor is the local equivalent.
Slot geometry was resolved once at startup and never revisited. A regenerated
genesis is routine on these devnets and silently invalidates every `offset_ms`
computed afterwards: nothing errors, every dot just piles up against one edge and
the panel quietly lies. Re-resolve every REFRESH_INTERVAL and republish through a
watch channel, so collectors pick it up per frame and `/api/meta` per request
without a restart.

On a change the retained history is dropped. Those events' offsets belong to the
previous epoch and their slot numbers to a different chain, so serving them in
the same backfill would mix two incomparable series. Dropping them also clears
the slot watermark, which matters more than the events: the ring only ever moves
that mark upward, so a chain restarted at low slots would otherwise have every
one of its events pruned as "older than the retain window". Verified against a
stub node: after the regen the ring holds the new chain's low slots with in-slot
offsets, which does not happen without the reset.

Already-loaded tabs keep the geometry and client-side watermark they fetched, so
they need a reload; that is now stated in CONTRACT.md §2 and the README rather
than left to be discovered.

Widen the arrival axis to two slots at two scales. It clamped to
[0, ms_per_slot], which made a block arriving a full slot late look exactly like
one landing on the boundary — and spilling past the boundary is the failure mode
worth seeing. The first slot keeps full resolution (90.9% of the width at 5
intervals) and the second is compressed into a tinted band half a first-slot
interval wide, saturating beyond that. Solving
`total = main + main * FRACTION / intervals` for the split keeps the band exactly
that fraction of an interval regardless of canvas width; checked numerically for
monotonicity and endpoints.

Document that `offset_ms` includes the collector<->node round trip. One clock is
deliberate and is what makes propagation deltas skew-free, since the events carry
no node-side timestamp, but it means nodes reached over different links carry a
systematic offset that reads as lag. Better stated than silently over-read.

Share retained events as Arc<NormalizedEvent>. Each event was deep-copied once
into the ring and once per broadcast receiver, and `/api/history` cloned up to
HISTORY_MAX_EVENTS of them while holding the mutex every collector needs to
publish. Serde's `rc` feature keeps the wire shape identical, as the contract
requires.

Reject `topics = []` and `nodes = []` at load. An empty topic list produces
`?topics=`, which every node answers with 400, so the collector retried forever
without ever saying why. Also drop tokio's `signal` feature, which was enabled
but unused, and fix the DEFAULT_INTERVALS_PER_SLOT comment, which described a
condition ("config didn't need a network fetch at all") that `bootstrap` never
has, since it always fetches.
Every finding here is the same defect class: the tool reporting a
healthy-looking state for something that is actually broken, which for a
monitor is worse than reporting nothing.

The shared HTTP client had no connect or read timeout, so a socket that
completes the handshake and then never answers parked the collector in
`send()` indefinitely. It reported `reconnecting` forever and `down` was
unreachable for that entire failure class, making a blackholed node
indistinguishable from one that is merely slow to come up; no retry was
ever even logged. A half-open connection where the node stops sending was
worse still: the heartbeat kept publishing `connected` at 0.0/s. Both
timeouts belong on the client, since `RequestBuilder::timeout` is a total
deadline that would tear down a healthy long-lived SSE stream on schedule.
The read timeout sits at three of upstream's 15s keep-alives, trading slow
convergence to `down` on a blackhole (a few minutes) for tolerance of a
loaded node.

A geometry change left every open tab permanently blank. The server resets
its slot watermark; the frontend's only ever moves up, so a restarted
chain's low slots computed an age past the window and were filtered out on
arrival. That reads as "the chain died", the one conclusion this tool must
not fabricate. The change is now broadcast on `/stream` as
`event: geometry` and the tab raises a reload banner instead of emptying
quietly. `?demo=1` also grows a clock-skewed node, since it was the one
path that could not exercise the negative-offset rendering below.

A negative `offset_ms` is documented as legitimate but was clamped to x=0,
where an event 30ms before its boundary drew identically to one landing on
it, inside the range this panel resolves. There is no room left of the lane
labels to plot it, so it still clamps, but keeps its topic fill and gains a
ring in the overflow accent: the same treatment the right edge already
gives the mirror-image case.

A missing `static_dir` started cleanly, logged that the dashboard was
ready, and answered `GET /` with 404. It defaults to the relative `web`,
so this fired on any invocation from outside `tooling/event-monitor/`;
`validate` exists for exactly this class and now checks for `index.html`.

The static fallback answered every unmatched path with `200 text/html`, so
a typo'd asset surfaced as a JS parse error and a wrong API path lost a
frozen contract's only wrong-path signal. This is one page, not a
client-routed SPA: `/` is routed explicitly and the rest 404s.

Bootstrap passed a malformed spec response straight through. Nothing
crashed (`slot_at` floors the divisor, the frontend defaults it), but every
dot piled against one edge unexplained, and a `genesis_time` off the wire
could overflow the `offset_ms` multiply, a debug-build panic. One range
check at the single point geometry enters the process covers both, and
covers the config overrides too. A future `genesis_time` stays legal: a
devnet is routinely configured before its genesis fires.

Three smaller things. The reconnect loop announced `Reconnecting` on its
way into every attempt, so a node that stayed down flipped its chip
red/amber/red once per cycle; an unchanged state is no longer
re-announced, while heartbeats still always publish their refreshed rate.
`/api/history` shipped up to HISTORY_MAX_EVENTS that the panels discard
against their own 2000-per-node cap, now capped per `(node, topic)` so an
attestation flood cannot crowd blocks out of the propagation backfill
either. The tooling CI steps pass `--locked`, so the committed Cargo.lock
is actually enforced.

CONTRACT.md gains `event: geometry` and the 404 rule in §4, the timeout and
geometry-validation rationale in §2, and the clamped-negative dot plus the
banner in §6.
@MegaRedHand
MegaRedHand merged commit 91eb0a1 into main Jul 29, 2026
4 checks passed
@MegaRedHand
MegaRedHand deleted the feat/event-monitor branch July 29, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant