Skip to content

feat(telemetry): add node telemetry v1 MVP - #4641

Draft
montycheese wants to merge 1 commit into
mainfrom
mux/telemetry
Draft

feat(telemetry): add node telemetry v1 MVP#4641
montycheese wants to merge 1 commit into
mainfrom
mux/telemetry

Conversation

@montycheese

@montycheese montycheese commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

This is the smallest end-to-end slice of node telemetry that proves the shape works: a consensus node collects a full node report, POSTs it to the existing base-telemetry service, and the service records it.

It ships dark. The reporting actor is None unless --telemetry.endpoint is set, so merging this cannot cause any node to report anywhere. Turning sending on is a separate change.

What changed

Two new crates. base-telemetry-types is the wire schema and nothing else, depended on by both the client and the service so the two cannot drift. base-telemetry-client is collection and transport — identity, hardware, latency sampling, and a ReportSink seam — deliberately client-agnostic so the execution node and snapshot CLI can reuse it without a move.

TelemetryActor in the consensus service is modeled on UpgradeSignalMetricsActor: same NodeActor impl, same interval-plus-cancellation select loop. define_telemetry_args! sits beside define_metrics_args! in base-cli-utils and flattens into BaseCli. base telemetry preview prints the exact payload a node would send, so the payload is reviewable before anything is ever sent.

On the service side, /v1/ingest lands in the existing base-telemetry-service behind the rate limiter already there, recording through a ReportRecorder seam whose one implementation writes JSONL and emits a structured event per report.

Design notes worth reviewing

The actor can never return Err. spawn_and_wait! installs a cancellation.drop_guard() per task, so any actor returning Err cancels the whole node. A telemetry failure taking down a Base node is the exact inversion of what this is for. Every recoverable error is logged and swallowed, and a test forces the sink to fail every call and asserts the actor stays up.

This reads typed in-process state rather than scraping the Prometheus registry. Two mutually exclusive recorders exist in this tree — BaseCli::run installs one, reth's install_prometheus_recorder() installs another and .expect()s — and only one can win set_global_recorder. They also disagree on naming, since reth's wraps everything in a PrefixLayer::new("reth"). A scraper would have to know which recorder won in order to know what a metric is called. Every value in this payload already has a typed source: heads from watch::Receiver<EngineState>, peers from P2pRpcRequest. Registry scraping earns its place when we want breadth across the ~40 crates using define_metrics!; that is a follow-up, not a prerequisite.

The wire format is snake_case, not the repo's camelCase HTTP convention. These become log facets, and the precedent is the transaction-event journal in observability-events, which is snake_case NDJSON for the same reason. It is a log record, not a browser-facing API.

schema_version is an integer. The receiver rule is "reject an unknown major," which an exact string match cannot express.

The telemetry ID is a UUID, and the migration is silent. Identities minted earlier were 32 undashed hex characters, which is exactly the simple UUID form, so an existing telemetry-id file parses unchanged and a node keeps its identity across the upgrade. There is a test pinning that.

The payload is flat. Everything at the top level answers which node is this; everything in a nested block answers what is it reporting. That is the split consumers actually use — the top level is what you group and filter by, the blocks are what you aggregate. A test pins the exact top-level key set so the contract cannot drift silently.

Rate limits key on the edge IP, never on anything in the payload.

Testing

Unit coverage across the sampler, identity, hardware collector, reporter queue-drop behavior, and the actor's failure tolerance; ingest tests for 202, 400, 413, 429 and a JSONL round-trip; and an etc/systems end-to-end test.

Draft because this has not been compiled since being rebased onto main. It was green beforehand — clippy clean, cargo +nightly fmt clean, 395 lib tests plus 2 integration tests — and the rebase produced one trivial import conflict, but CI is the first real check on the merged result. cargo metadata --locked does pass, so the manifests and lockfile are at least internally consistent.

The system test needs a live devnet and has not been re-run since #4447 reshaped stack bring-up.

Not in this PR

Nothing enables sending. There is no public ingress and no remote kill switch, so this is deliberately not deployable to third-party nodes as-is. The execution-layer client is also out of scope; v1 is consensus-only.

@cb-heimdall

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

/// The actor starts with an empty set, so the first fold counts the node's initial peers as
/// joins. That is the honest reading of "peers joined since this reporter started".
pub fn fold_peer_churn(&mut self, current: HashSet<String>) {
self.peers_joined += current.difference(&self.connected_peers).count() as u32;

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.

nit: The as u32 casts on difference().count() can silently truncate if a HashSet has more than u32::MAX entries. In practice the peer set will never be that large, but u32::try_from(...).unwrap_or(u32::MAX) or a saturating cast would be more defensive. Same for peers_joined / peers_left which are u32 accumulators — consecutive folds with large peer sets could theoretically overflow via wrapping addition.

Low severity since peer counts are realistically small, but these are public methods.

}

/// Returns whether this config will actually send anything.
pub const fn is_active(&self) -> bool {

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.

is_active is const fn but calls Option::is_some(), which is a const fn in current Rust. However, this means is_active cannot evolve to include non-const checks (e.g. URL validation) without a breaking signature change. Minor, but worth noting since the method sits on a public configuration type.


/// Returns the number of reports the background writer has dropped.
pub fn dropped(&self) -> u64 {
self.inner.errors.as_ref().map_or(0, |errors| errors.dropped_lines() as u64)

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.

ErrorCounter::dropped_lines() returns usize, cast here as u64. On 32-bit platforms this is fine (widening), but on a hypothetical platform where usize > u64 it would truncate. Minor, but as u64 silently truncates in the general case — consider using .into() or u64::try_from().unwrap_or(u64::MAX) if the crate targets strict portability.

More importantly, ErrorCounter tracks errors across the writer's lifetime. report_new_drops uses swap to track what was previously observed, but dropped() returns the writer's total error count. If the error counter ever wraps (unlikely but theoretically possible for usize on 32-bit), the dropped > previous check would produce a false negative for one cycle. Not a practical concern for this use case.

///
/// A file that exists but does not hold a well-formed ID is replaced rather than treated as
/// fatal. A truncated write from a crash should not stop a node from starting.
pub fn load_or_create(path: &Path) -> Result<Self, TelemetryIdError> {

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.

TOCTOU race on identity file: load_or_create first tries to read the file, and if it's absent, generates a new ID and writes it. If two processes (or a restart race) call this concurrently, both can read None, both generate different IDs, and the last writer wins — silently changing the node's identity.

This is unlikely in the single-process node case, but load_or_create is a pub method on a pub type. Consider using O_CREAT | O_EXCL (exclusive create) on the write path and retrying the read if the exclusive create fails, which makes the mint atomic on the filesystem.

Low severity since the PR description acknowledges identity is "reliable within a reporting window and not across months."

Comment on lines +226 to +237
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct NodeReportEvent {
/// When ingest accepted the report.
pub received_at: DateTime<Utc>,
/// The address attributed to the reporting node.
pub reported_ip: IpAddr,
/// Whether `reported_ip` came from the node or from the connection.
pub ip_source: IpSource,
/// The report as the node sent it.
#[serde(flatten)]
pub report: NodeReport,
}

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.

NodeReportEvent uses #[serde(flatten)] on report: NodeReport, and NodeReport itself uses #[serde(flatten)] on client: ClientMeta. Nested flatten is known to have performance issues with serde (quadratic deserialization in some cases) and can produce confusing errors when field names collide. Currently all field names are distinct so correctness is fine, but adding a field to NodeReportEvent that collides with any NodeReport or ClientMeta field name will silently shadow it.

Consider documenting this constraint or adding a test that asserts no key collisions across the flattened levels (the existing test_the_payload_carries_exactly_the_specified_top_level_keys test partially covers NodeReport but not the NodeReportEvent wrapper).

Comment on lines +114 to +120
IngestApiError::from_json_rejection(&rejection)
})?;

if !report.is_current_schema() {
// Accepted anyway: an old or new node must keep reporting across a schema change,
// and every field that did parse is still worth having.
warn!(

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.

The ingest_node_report handler accepts ConnectInfo<SocketAddr> which requires into_make_service_with_connect_info::<SocketAddr>() on the Axum server. This is correctly done in server.rs:140 and in the tests, but if someone wires this router without into_make_service_with_connect_info, the handler will panic at runtime on the first request rather than failing at compile time. This is an Axum footgun rather than a code issue, but worth noting since the IngestRoutes::router method is public and doesn't document this requirement.

Adds an end-to-end slice of node telemetry: a consensus node
collects a full node report, POSTs it to the existing base-telemetry
service, and the service records it as JSONL.

New crates:
- base-telemetry-types: wire schema only, shared by client and service so
  the two cannot drift.
- base-telemetry-client: identity, hardware collection, latency sampling,
  and a ReportSink seam for transport.

Wiring:
- TelemetryActor in the consensus service, modeled on
  UpgradeSignalMetricsActor. It never returns Err, because spawn_and_wait!
  turns any actor error into a full node shutdown.
- define_telemetry_args! in base-cli-utils, flattened into BaseCli.
- `base telemetry preview` prints the exact payload a node would send.
- /v1/ingest in base-telemetry-service behind the existing per-IP rate
  limiter, recording through a JsonlRecorder.

Reports are read from typed in-process state rather than a scraped
Prometheus registry: two mutually exclusive recorders exist in this tree
and rendered metric names differ depending on which one wins.

Ships dark. The actor is None unless --telemetry.endpoint is set, so
merging this cannot cause any node to report anywhere.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
.join(".base")
.join(l2_chain_id.to_string())
.join(TELEMETRY_ID_FILE_NAME)
}

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.

nit: default_id_path falls back to PathBuf::from(".") when $HOME is unset, which puts the identity file at ./.base/<chain_id>/telemetry-id relative to the working directory. On a containerized node started by systemd, $HOME is often absent and the working directory is /, so the file lands at /.base/8453/telemetry-id. This works, but a container rebuild or a different working directory will silently mint a new identity. Consider using dirs_next::home_dir() (already a workspace dependency) which also checks /etc/passwd, or at least documenting that --telemetry.id-path should be set in container deployments.


impl fmt::Debug for JsonlRecorder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JsonlRecorder")

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.

The RecorderInner struct holds Option<WorkerGuard> as _guard. When the JsonlRecorder (and all its Arc clones) are dropped, this guard is dropped too, which flushes and joins the background writer thread. In server.rs, the recorder Arc lives for the lifetime of the server — fine.

However, there's a subtlety: ReportRecorder::record() calls writer.write_all(&line) on a cloned NonBlocking. If the WorkerGuard is dropped (e.g. during shutdown) while a concurrent record() call is in flight on another thread, write_all will fail because the internal channel is closed. This is handled by the if let Err(error) on line 170, so no panic. But report_new_drops() on line 172 may undercount because the ErrorCounter is also dropped with the guard.

Not a bug since shutdown ordering means no new requests arrive, but worth noting that the recorder should be the last thing dropped during graceful shutdown if accurate drop counts matter.

self.peers_joined += current.difference(&self.connected_peers).count() as u32;
self.peers_left += self.connected_peers.difference(&current).count() as u32;
self.connected_peers = current;
}

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.

The report() method calls connected_peer_ids().await unconditionally at the top, then calls net_health().await which internally calls local_peer_info().await and discovered_peer_count().await. That's 3 sequential p2p RPC round trips per report cycle, each with a 5-second timeout.

In the worst case (p2p actor wedged), a single report cycle takes 15 seconds of wall-clock time before all three queries time out. With the default 15-minute report interval this is negligible, but if an operator configures a shorter interval (e.g. 10 seconds via --telemetry.report-interval), the report path could consume more than the full interval, causing report ticks to pile up and be skipped.

Consider either (a) issuing the 3 queries concurrently with tokio::join!, or (b) documenting a minimum report interval that exceeds 3 × P2P_QUERY_TIMEOUT.

@github-actions

Copy link
Copy Markdown
Contributor

Review Summary

This PR adds a full telemetry stack: base-telemetry-types (wire schema), base-telemetry-client (collection/transport), a TelemetryActor in the consensus service, ingest handling in base-telemetry-service, and system test coverage. The design is sound — telemetry ships dark (no endpoint configured by default), the actor uses Infallible error type to prevent node crashes, failures are logged-and-swallowed, and the delivery queue is bounded and lossy.

Block-production sensitivity

The TelemetryActor is spawned inside spawn_and_wait! which installs a CancellationToken drop guard. The Error = Infallible constraint correctly prevents the actor from returning Err and cancelling the node. The actor reads from a watch::Receiver (non-blocking) and sends to a shared mpsc::Sender (1024-capacity p2p_rpc channel), adding negligible load. No block-production halt/stall risk identified.

Findings

All findings are low severity. No critical issues found.

  1. default_id_path $HOME fallback (config.rs): Falls back to "." when $HOME is unset (common in containers), which can cause identity churn across restarts.

  2. Sequential p2p queries in report cycle (telemetry.rs): Three sequential RPC round trips per report, each with a 5s timeout. With a short custom report interval, the report path could exceed the interval. Could be parallelized with tokio::join!.

  3. WorkerGuard drop ordering (recorder.rs): Minor observation about ErrorCounter availability during shutdown when the guard drops.

Overall assessment

Well-engineered telemetry MVP with strong safety properties. The "ships dark" approach, Infallible error type, bounded lossy queue, and delivery streak throttling are all the right choices. Test coverage is thorough — unit tests, integration round-trip tests, and an end-to-end system test. The shared wire types crate prevents client/server schema drift.

The existing inline comments from previous reviews cover the as u32 truncation casts, TOCTOU on identity, and nested #[serde(flatten)] considerations, all of which are low severity for this use case.

@github-actions

Copy link
Copy Markdown
Contributor

Base Std historical fork tests

Fork Result Passed Failed Skipped base/base base-anvil base-std
Beryl pass 616 0 13 bb4e6a34 8d0f5b8a 4658f1b7
Cobalt pass 721 0 14 bb4e6a34 9df661bc e30b3421

View run

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.

2 participants