Skip to content

feat: Add subnet_metrics management canister endpoint - #11032

Open
Dfinity-Bjoern wants to merge 4 commits into
masterfrom
subnet-metrics-endpoint
Open

feat: Add subnet_metrics management canister endpoint#11032
Dfinity-Bjoern wants to merge 4 commits into
masterfrom
subnet-metrics-endpoint

Conversation

@Dfinity-Bjoern

Copy link
Copy Markdown
Contributor

Implements the EXPERIMENTAL subnet_metrics management canister endpoint specified in dfinity/developer-docs#333.

type subnet_metrics_args = record { subnet_id : principal };

type subnet_metrics_result = record {
    block_height : nat;
    num_canisters : nat;
    canister_state_bytes : nat;
    consumed_cycles_total : nat;
    update_transactions_total : nat;
};

Canister-callable only; not reachable via ingress. subnet_id may name any subnet — routing delivers the call to the named subnet, so the args.subnet_id == own_subnet_id check mirrors node_metrics_history and does not block cross-subnet calls. It guards the NNS direct-subnet-addressing path, where a call can reach subnet A while naming subnet B and would otherwise be answered with subnet A's metrics.

Four of the five values were previously readable only by external users via the certified state tree at /subnet/<subnet_id>/metrics, which canisters cannot read. block_height is genuinely new: it is current_round, already deterministic at execution time — the same value vetkd_derive_key commits to replicated state today.

No replicated-state, protobuf, or certification-version change. No Cargo.toml or Bazel change.

Design points worth reviewer attention

The instruction charge is keyed on hot_len(), not num_canisters(). total_consumed_cycles() folds over hot canisters only, and hot_len() << len() is the steady state, so keying on the total over-charges ~41× at 100k canisters. That does not protect the subnet — it lets ~61 calls/round pin the entire shared 250M subnet-message budget and defer install_code / upload_chunk / snapshot traffic, for roughly $3.5/day, from a canister with no foothold on the target subnet. Priced against the already-enabled fetch_canister_logs (2.4 cycles per round-instruction of budget consumed), the hot-keyed endpoint costs an attacker 3.6. subnet_metrics_charge_ignores_cold_canisters fails if this regresses.

hot_len() is the first partition-cardinality input to execution. That makes the unconditional repartition_canister_states() call in commit_and_certify a correctness requirement rather than an optimisation — moving it inside the CertificationScope::Metadata branch would diverge the charge across replicas. Determinism rests on four legs: is_cold() is time-independent; the partition is never serialized (every load path derives it via CanisterStates::new); repartitioning is unconditional at commit, so committed equals derived at every height; and all four state-acquisition paths therefore agree. hot_cold_partition_is_canonical_after_every_commit guards the coupling.

consumed_cycles_total uses the corrected V29 computation. CURRENT_CERTIFICATION_VERSION is V27, so the certified state tree still reports the legacy double-counting value and the two will disagree until V29 becomes current. This was a deliberate choice: report the correct number from day one rather than bake a known bug into a new interface.

canister_state_bytes is read from the stored field, never recomputed. The stored value refreshes only every 10 rounds by design, so a live recomputation would disagree with the state tree on 9 rounds out of 10.

validate_cold_stats() alerts; it does not enforce. validate_eq_checkpoint discards the error and the checkpoint still finalizes, with detection lagging a checkpoint interval. It is a strict improvement — ColdStats is already consensus-critical today via cold_stats.memory_usagecanister_state_bytes, with no check at all — but it should be described as detection, not prevention.

Verification

cargo check --all-targets clean; cargo fmt -- --check exit 0; targeted clippy with --deny warnings clean apart from three pre-existing unused_imports. Roughly 80 of the 464 depth-2 Bazel targets run, including both candid_equality targets and all subnet_metrics unit tests. Candid fixtures verified byte-identical to the spec in both crates.

Not verified locally, and needing CI:

  • The five system tests in rs/tests/execution/general_execution_tests/api_tests.rs are Linux-only and have never been compiled. This includes the cross-subnet test — the spec's headline capability — and the composite-query rejection test. Symbols were hand-checked; CI is their first real check.
  • ./ci/scripts/rust-lint.sh could not complete: its clippy step is --all-features --workspace, which hits a pre-existing break at rs/types/management_canister_types/src/lib.rs:2431 unrelated to this change.
  • //rs/state_manager:state_manager_integration / lsmt_merge_overhead fails, reproduced identically on a clean tree.
  • The end_to_end benchmark group was not measured, so the 100K base constant is estimated from the handler's fixed work rather than measured. It errs generous. A suggested pass line for a future run is ≤ 50 µs/call.

Follow-ups, out of scope here

ic-cdk and Motoko bindings; optionally adding block_height to the state tree so external users get it in certified form; consensus sign-off on introducing a block-height concept to the interface spec.

🤖 Generated with Claude Code

Implements the EXPERIMENTAL `subnet_metrics` endpoint from
dfinity/developer-docs#333: given a subnet ID, returns that subnet's
current block height, canister count, total canister state size, total
consumed cycles, and total processed transactions. Canister-callable
only; not reachable via ingress.

Four of the five values were previously readable only by external users
via the certified state tree at /subnet/<subnet_id>/metrics, which
canisters cannot read. `block_height` is new: it is `current_round`,
already deterministic at execution time (the same value `vetkd_derive_key`
already commits to replicated state).

`subnet_id` may name any subnet. Routing delivers the call to the named
subnet, so the `args.subnet_id == own_subnet_id` check in the handler
mirrors `node_metrics_history` and does not block cross-subnet calls; it
guards the NNS direct-subnet-addressing path, where a call can reach
subnet A while naming subnet B.

Notes for future readers, since these are easy to "fix" back:

* The instruction charge is keyed on `hot_len()`, NOT `num_canisters()`.
  The fold in `total_consumed_cycles()` visits hot canisters only, and
  `hot_len() << len()` is the steady state. Keying on the total
  over-charges ~41x at 100k canisters, which does not protect the subnet
  — it lets ~61 calls/round pin the whole shared subnet-message budget
  and defer install_code/snapshot traffic. Priced against the already
  enabled `fetch_canister_logs` (2.4 cycles per round-instruction of
  budget), hot-keyed `subnet_metrics` costs an attacker 3.6.
  `subnet_metrics_charge_ignores_cold_canisters` fails if this regresses.

* `hot_len()` is the first partition-cardinality input to execution, so
  the unconditional `repartition_canister_states()` call in
  `commit_and_certify` is now a correctness requirement, not an
  optimisation. Moving it inside the `CertificationScope::Metadata`
  branch would diverge the charge across replicas.
  `hot_cold_partition_is_canonical_after_every_commit` guards this.

* `canister_state_bytes` is read from the stored `subnet_metrics` field
  and must not be recomputed live: the stored value refreshes only every
  10 rounds by design, so recomputing would disagree with the certified
  state tree on 9 rounds out of 10.

* `validate_cold_stats()` alerts; it does not enforce.
  `validate_eq_checkpoint` discards the error and the checkpoint still
  finalizes. Describe it as detection, not prevention.

* The system tests in general_execution_tests/api_tests.rs are Linux-only
  and could not be compiled locally. CI is their first real check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

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.

Pull request overview

Implements the experimental subnet_metrics management canister endpoint across the IC execution stack (routing, permissions, metering, and state access), plus adds unit/system tests and benchmarks to pin determinism and cost-model behavior.

Changes:

  • Adds SubnetMetrics as a new management canister method with canister-callable-only enforcement and composite-query rejection.
  • Implements execution logic returning subnet metrics (including block_height) with round-instruction charging keyed to hot_len().
  • Adds broad regression coverage (execution env, state manager/checkpoint validation, routing tests, candid fixtures, and criterion benches).

Reviewed changes

Copilot reviewed 33 out of 33 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
rs/types/types/src/messages/inter_canister.rs Treats SubnetMetrics as having no effective canister id for canister calls.
rs/types/types/src/messages/ingress_messages.rs Ensures SubnetMetrics is treated as a subnet method (not allowed for ingress).
rs/types/management_canister_types/tests/ic.did Adds Candid fixture types + service method for subnet_metrics.
rs/types/management_canister_types/tests/candid_equality.rs Extends candid-equality coverage with subnet_metrics stub.
rs/types/management_canister_types/src/lib.rs Adds Method::SubnetMetrics and public arg/response Rust types.
rs/tests/execution/general_execution_tests/api_tests.rs Adds end-to-end system tests for own-subnet, cross-subnet, and query/composite-query failure modes.
rs/tests/execution/general_execution_test.rs Registers the new subnet_metrics system tests in the execution test group.
rs/test_utilities/execution_environment/src/lib.rs Exposes and plumbs current_round in the test harness; updates round-instruction special-casing.
rs/state_manager/tests/state_manager.rs Adds a regression test pinning unconditional repartitioning for deterministic hot_len()-keyed charging.
rs/state_manager/src/lib.rs Documents why repartition_canister_states() must remain unconditional.
rs/state_manager/src/checkpoint.rs Adds advisory cold_stats aggregate validation during checkpoint validation and preserves per-canister diagnostics.
rs/replicated_state/src/replicated_state.rs Strengthens documentation for repartitioning as a correctness requirement (not just an optimization).
rs/replicated_state/src/canister_states/tests.rs Adds tests for total_consumed_cycles correctness and validate_cold_stats behavior.
rs/replicated_state/src/canister_states.rs Adds validate_cold_stats() API and documents its advisory validation role.
rs/execution_environment/tests/execution_test.rs Adds instruction-accounting tests for subnet_metrics (budget respect + hot-vs-total scaling + block height progression).
rs/execution_environment/src/scheduler.rs Special-cases SubnetMetrics like ListCanisters for round-limit gating and adds instruction-limit selection entry.
rs/execution_environment/src/ic00_permissions.rs Documents/records that SubnetMetrics bypasses effective-canister-id permission path.
rs/execution_environment/src/execution_environment.rs Implements SubnetMetrics handler, response construction, and hot-keyed round-instruction charge function.
rs/execution_environment/src/execution_environment_metrics.rs Extends metrics labeling to include SubnetMetrics.
rs/execution_environment/src/canister_manager/tests.rs Adds focused unit tests for canister-only access, block height, foreign-subnet rejection, payload decoding, etc.
rs/execution_environment/src/canister_manager.rs Adds SubnetMetrics to ingress-filter rejection arm for defense in depth.
rs/execution_environment/benches/management_canister/test_canister/src/main.rs Adds a test-canister helper method to call subnet_metrics for benchmarks.
rs/execution_environment/benches/management_canister/test_canister/candid.did Updates the benchmark test canister Candid interface with subnet_metrics.
rs/execution_environment/benches/management_canister/subnet_metrics.rs New benchmarks measuring end-to-end and hot-fold costs for subnet_metrics.
rs/execution_environment/benches/management_canister/main.rs Registers the new subnet_metrics benchmark module.
rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs Allows SubnetMetrics through the system-state-modifications method allowlist.
rs/embedders/src/wasmtime_embedder/system_api/routing.rs Adds routing for SubnetMetrics (payload subnet) and explicit composite-query rejection + tests.
rs/canonical_state/src/encoding/tests/subnet_metrics.rs New cross-check ensuring consumed_cycles_total matches canonical encoding at V29.
rs/canonical_state/src/encoding.rs Registers the new canonical-state subnet-metrics test module.
packages/ic-management-canister-types/tests/ic.did Updates public-package Candid fixture with subnet_metrics types + service method.
packages/ic-management-canister-types/tests/candid_equality.rs Adds candid-equality stub method for subnet_metrics.
packages/ic-management-canister-types/src/lib.rs Adds exported SubnetMetricsArgs / SubnetMetricsResult types (public bindings).
packages/ic-management-canister-types/CHANGELOG.md Notes addition of subnet_metrics types in the package changelog.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread rs/types/management_canister_types/tests/ic.did
Comment thread packages/ic-management-canister-types/tests/ic.did
Comment thread packages/ic-management-canister-types/src/lib.rs Outdated
Bjoern Tackmann and others added 2 commits August 5, 2026 11:16
…reshness

Addresses the CI failure and the Copilot review. No production logic
changed; this is test code and doc comments only.

**Composite-query system test.** `subnet_metrics_composite_query_fails`
asserted that the routing rejection's message reaches the caller. It does
not: `reject_subnet_message_routing`'s synthesized response is never
delivered on the query path, so the universal canister never replies and
the outer query fails `CanisterError` / "did not produce a response".

This is established platform behaviour of the composite-query arm in
`resolve_destination`, not something this change introduced. A control
experiment showed `fetch_canister_logs` — which has the identical arm and
ships enabled — behaves identically, while `canister_status`, which has no
such arm, does deliver its reject (no arm means the request is created and
`QueryContext::handle_request`'s reject is delivered normally).

The test now asserts the real behaviour and says plainly that this makes it
weak: it cannot distinguish the arm from any other failure to reply, and
would pass against a stub. The method-specific assertion lives in
`resolve_subnet_metrics_rejects_composite_query` in `routing.rs`, which
tests `resolve_destination` directly. The division of labour is: the unit
test proves the arm, the system test documents user-visible behaviour. The
now-inert `.on_reject(...)` is kept deliberately, so that if the platform
ever does deliver the reject, the test fails loudly rather than quietly
continuing to assert the swallowed behaviour.

All five `subnet_metrics` system tests now pass, verified by execution on a
Linux host rather than by inspection — including the cross-subnet
attribution test, which is the first genuine cross-subnet management-call
test in the repo.

**Field freshness docs.** Per review, the Rust doc comments described values
as "current" when four of the five lag: only `block_height` is current, the
other four are as of end-of-previous-round, and `canister_state_bytes` is
refreshed only every 10 rounds (so it reads 0 early in a subnet's life).
Documented on both `SubnetMetricsResult` and `SubnetMetricsResponse`.

The review also asked for the same wording change in the two `ic.did`
fixtures. Deliberately not done: those must stay byte-identical to the
upstream spec's `public/references/ic.did`. That wording fix belongs in
dfinity/developer-docs#333, which already carries an open item on imprecise
gauge-vs-counter wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three simplifications, no behaviour change. Net -296 insertions, -4 files.

**Share the consumed-cycles formula instead of testing for drift.**
`SubnetMetrics::consumed_cycles_total_including_canisters()` is now called
by both the canonical-state encoder and the `subnet_metrics` handler, so the
invariant is structural rather than pinned by a cross-check test. That test
(78 lines) and its reciprocal keep-in-sync comments are deleted.

The method lives on `SubnetMetrics` rather than `ReplicatedState` because
`SubnetMetrics::from` — where the state tree does the addition — has no
`ReplicatedState` in scope. Only the `>= V29` branch is rerouted through the
new method; the `<= V28` branch still calls `consumed_cycles_total_v28()`
untouched, so no state hash at any existing certification version moves.

**Drop the `end_to_end` benchmark group.** It was never successfully
measured, and the per-canister constant came from `bench_consumed_cycles_fold`
instead. Its test-canister plumbing goes with it, restoring
`benches/management_canister/test_canister/` to its previous state. The
base-cost doc comment now states plainly that the base is estimated from the
handler's fixed work and was never measured end to end, rather than pointing
at a benchmark that no longer exists.

**Move `validate_cold_stats()` out to its own change.** It is hardening for
pre-existing code, not a requirement of this endpoint: `ColdStats` is already
consensus-critical today via `canister_state_bytes`, with no check at all.
The determinism argument for reading `hot_len` does not depend on it — it
rests on `is_cold()` being time-independent, the partition never being
serialized, unconditional repartitioning at commit, and all four state
acquisition paths agreeing. `rs/state_manager/src/checkpoint.rs` and
`rs/replicated_state/src/canister_states.rs` are byte-identical to master
again.

What deliberately stays, because it guards a coupling *this* change
introduces rather than the removed check:
`hot_cold_partition_is_canonical_after_every_commit`, the
`repartition_canister_states` doc comment, and the test that
`total_consumed_cycles()` equals a direct fold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zeropath-ai

zeropath-ai Bot commented Aug 5, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 0e795b7.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► packages/ic-management-canister-types/src/lib.rs
    + Added SubnetMetricsArgs and SubnetMetricsResult structs and related fields/methods
► packages/ic-management-canister-types/tests/candid_equality.rs
    + Added subnet_metrics candid method stubs for testing
► packages/ic-management-canister-types/tests/ic.did
    + Extended DID with subnet_metrics types and endpoint
► rs/canonical_state/src/encoding/types.rs
    + Updated metrics handling to use consumed_cycles_total_including_canisters for V29+
► rs/embedders/src/wasmtime_embedder/system_api/routing.rs
    + Routing support for SubnetMetrics payloads (SubnetMetricsArgs) and related tests
► rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs
    + Treat SubnetMetrics as a selectable system API in routing guard
► rs/execution_environment/benches/management_canister/main.rs
    + Benchmark inclusion for subnet_metrics
► rs/execution_environment/benches/management_canister/subnet_metrics.rs
    + New benchmark file for subnet_metrics consumed cycles fold
► rs/execution_environment/src/canister_manager.rs
    + Exhaustiveness: treat SubnetMetrics as a possible Ic00Method in match arms
► rs/execution_environment/src/canister_manager/tests.rs
    + Tests and helpers for subnet_metrics inter-canister call flow
► rs/execution_environment/src/execution_environment.rs
    + SubnetMetrics handling: ingress/execute path and response construction
► rs/execution_environment/src/execution_environment_metrics.rs
    + Include SubnetMetrics in allowed ic00 methods for metrics path
► rs/execution_environment/src/ic00_permissions.rs
    + Permit SubnetMetrics in Ic00MethodPermissions with special handling
► rs/execution_environment/src/scheduler.rs
    + Defer ListCanisters and SubnetMetrics when round limit reached
► rs/execution_environment/tests/execution_test.rs
    + Include SubnetMetrics in test scaffolding for Ic00 arguments

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated no new comments.

Per review feedback. Behaviour-neutral: `counts_toward_round_limit` is read
only in `Ic00MethodPermissions::can_be_executed`, whose single call site in
the repo is `scheduler.rs:1847` — and `can_execute_subnet_msg` returns
earlier, at the `ListCanisters | SubnetMetrics` special case, so the flag is
unreachable for this method. Both affected test targets pass unchanged.

The flag now records that the method does consume round instructions. Two
comments had to change to keep the tree self-consistent:

* The note in `ic00_permissions.rs` no longer says the flag is unset because
  it is not consulted. It states that the flag is not consulted, and warns
  that the deferral comes from the dedicated special case in
  `can_execute_subnet_msg`, which must not be removed on the strength of
  this flag.

* The doc on `check_consumes_round_instructions_without_effective_canister_id`
  said such methods "cannot use `Ic00MethodPermissions::counts_toward_round_limit`",
  which is no longer accurate for `subnet_metrics`. It now says the flag is
  never consulted for them and so cannot identify them whatever its value —
  true for both entries.

`ListCanisters` is in the identical position and remains `false`. Aligning it
would be more consistent but changes pre-existing configuration outside this
change's scope; the asymmetry is noted in the comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants