Skip to content

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

Draft
MegaRedHand wants to merge 1 commit into
mainfrom
feat/event-monitor
Draft

feat(tooling): add event-monitor arrival-time dashboard#538
MegaRedHand wants to merge 1 commit into
mainfrom
feat/event-monitor

Conversation

@MegaRedHand

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 / head, 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 only the tooling. The RPC / event-bus changes that make a
node emit these events are out of scope here and ship in the chain-events series
(see below).

Required ethlambda API functionality

The monitor consumes chain events over SSE plus two bootstrap endpoints for slot
geometry. Dependency status:

Capability Endpoint / topic(s) Status PR
SSE events stream GET /lean/v0/events ✅ on main #517
Topic filtering ?topics=<csv> ✅ on main #518
Slot-geometry bootstrap GET /lean/v0/genesis, GET /lean/v0/config/spec ✅ on main
Base topics block, head, justified_checkpoint, finalized_checkpoint ✅ on main #516
Attestation / aggregate topics attestation, aggregate ⏳ open #534
Reorg / safe-target topics safe_target, chain_reorg ⏳ open #533
Block-gossip topic block_gossip ⏳ open #535

Required for the default dashboard view: #534. Both panels default to the
block / attestation / aggregate topics. block and head already work
against main, but attestation and aggregate need #534 before the rolling
beeswarm and the aggregate propagation view populate.

Optional enrichment: #533 (safe_target, chain_reorg) and #535
(block_gossip). The collector parses these topics if a node emits them, but
the dashboard does not require them.

Until #534 lands, the monitor is usable with topics = ["block", "head"] for a
block-only slot-arrival and propagation view.

Testing

  • cargo test --release in tooling/event-monitor/: 29 passed (27 lib + 2 integration), 0 failed.
  • ?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).

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 live event-arrival dashboard for ethlambda nodes. The main changes are:

  • A Rust collector for node SSE streams and slot timing.
  • A bounded history store and merged browser SSE API.
  • Rolling arrival-time and propagation beeswarm views.
  • Offline demo data and deployment documentation.

Confidence Score: 4/5

The default connection path and out-of-order history pruning need fixes before merging.

  • The default topics cause the current node API to reject the whole SSE subscription.
  • Delayed old events can remain in history and displace valid recent events.
  • The remaining stream, normalization, and browser backfill flow is internally consistent.

tooling/event-monitor/src/config.rs and tooling/event-monitor/src/hub.rs

Important Files Changed

Filename Overview
tooling/event-monitor/src/config.rs Defines collector defaults, including a topic set that the current upstream endpoint rejects.
tooling/event-monitor/src/collector.rs Adds per-node SSE collection, normalization, status reporting, and reconnect backoff.
tooling/event-monitor/src/hub.rs Adds merged broadcasting and bounded history, but slot pruning assumes events arrive in slot order.
tooling/event-monitor/src/model.rs Adds event payload normalization and stable aggregate identity generation.
tooling/event-monitor/src/timing.rs Adds slot-geometry bootstrap and arrival-offset calculations.
tooling/event-monitor/src/server.rs Adds metadata, history, live SSE, and static dashboard routes.
tooling/event-monitor/web/app.js Adds stream-first startup, history backfill, overlap deduplication, and status rendering.
tooling/event-monitor/web/beeswarm.js Adds the rolling per-node arrival-offset visualization.
tooling/event-monitor/web/propagation.js Adds grouped per-node propagation-delta visualization.

Sequence Diagram

sequenceDiagram
    participant N as ethlambda nodes
    participant C as Event collectors
    participant H as Hub and history
    participant A as Axum API
    participant B as Browser
    C->>N: "GET /lean/v0/events?topics=..."
    N-->>C: SSE events
    C->>C: Timestamp and normalize
    C->>H: Store then broadcast
    B->>A: Open /stream
    A->>H: Subscribe
    B->>A: GET /api/history
    A->>H: Read snapshot
    A-->>B: History response
    A-->>B: Live chain and status events
    B->>B: Deduplicate and render
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
tooling/event-monitor/src/config.rs:57-63
**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.

### Issue 2 of 2
tooling/event-monitor/src/hub.rs:74-80
**Late Events Escape Slot Pruning**

This loop stops at the first recent front element, so an old event received out of slot order remains at the back of the deque even when it is outside `retain_slots`. Repeated delayed events can occupy the 50,000-event cap and force valid in-window events out of history, leaving a refreshed dashboard with missing recent data.

```suggestion
        self.events.retain(|event| {
            self.max_slot.saturating_sub(event.slot) < self.retain_slots
        });
```

Reviews (1): Last reviewed commit: "feat(tooling): add event-monitor arrival..." | 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 on lines +74 to +80
while let Some(front) = self.events.front() {
if self.max_slot.saturating_sub(front.slot) >= self.retain_slots {
self.events.pop_front();
} else {
break;
}
}

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 Late Events Escape Slot Pruning

This loop stops at the first recent front element, so an old event received out of slot order remains at the back of the deque even when it is outside retain_slots. Repeated delayed events can occupy the 50,000-event cap and force valid in-window events out of history, leaving a refreshed dashboard with missing recent data.

Suggested change
while let Some(front) = self.events.front() {
if self.max_slot.saturating_sub(front.slot) >= self.retain_slots {
self.events.pop_front();
} else {
break;
}
}
self.events.retain(|event| {
self.max_slot.saturating_sub(event.slot) < self.retain_slots
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: tooling/event-monitor/src/hub.rs
Line: 74-80

Comment:
**Late Events Escape Slot Pruning**

This loop stops at the first recent front element, so an old event received out of slot order remains at the back of the deque even when it is outside `retain_slots`. Repeated delayed events can occupy the 50,000-event cap and force valid in-window events out of history, leaving a refreshed dashboard with missing recent data.

```suggestion
        self.events.retain(|event| {
            self.max_slot.saturating_sub(event.slot) < self.retain_slots
        });
```

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

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