Context and scope
avalanche_platformvm_local_staked reports the weight of this node in the Primary Network validator set, which is the node's own stake plus every delegation to it, summed into a single value. The two components are merged well before the metric is emitted: AddStaker seeds the validator's own weight and each delegator is folded in via AddWeight (vms/platformvm/state/state.go:2257-2269 on load; weightChanges() at vms/platformvm/state/stakers.go:286-316 on the incremental path, whose doc comment states "The added weight includes the added validator and all added delegators"). By the time SetLocalStake is called, only the sum survives.
There is no metric that exposes the delegated portion on its own, so operators who want to track delegation to their own nodes have to poll platform.getCurrentValidators and read delegatorWeight, rather than reading it off the existing metrics pipeline. Aggregating across several nodes programmatically is also challenging and would require hand-baked scripts instead of a simple metrics query.
A related source of confusion motivates this: avalanche_platformvm_total_staked is not node-scoped, it is TotalWeight(PrimaryNetworkID), the stake of the entire network, and reads the same on every node including non-validators. Operators reasonably reach for it expecting a per-node number and get a network-wide one.
Goals:
- Expose delegated stake as a first-class metric, so delegation to a node can be alerted on and graphed without an API call.
- Do so without changing the meaning or shape of local_staked or total_staked.
Discussion and alternatives
Proposed: add avalanche_platformvm_local_delegated_staked, a gauge reporting the nAVAX delegated to this node on the Primary Network. It is derived at the existing metric update sites as GetWeight(PrimaryNetworkID, ctx.NodeID) - <this node's own validator weight>, with the own weight read from GetCurrentValidator. Both existing update sites (initValidatorSets and updateValidatorManager) are factored into a shared updateStakeMetrics helper so the three stake gauges are always written from one consistent snapshot of the validator manager.
Alternative 1 — a label on local_staked (e.g. source="self"|"delegated"). Rejected. local_staked is currently a plain unlabeled prometheus.Gauge; promoting it to a GaugeVec turns one series into two, so existing dashboards and threshold alerts silently change behavior with nothing to signal the break. It also forces a choice between publishing the sum and publishing the parts, you cannot have both under one metric name without a source="total" value that double-counts on aggregation. The labeled gauges already in this package (excess, price, time_until_unstake_subnet) all label over an open-ended dimension (which resource, which subnet), not a fixed two-way split of an existing scalar.
Alternative 2 — expose the node's own stake instead (local_self_staked) and let PromQL subtract. Equivalent information, but pushes arithmetic into every dashboard and alert, and reads worse for the common case. Rejected in favor of publishing the value operators actually want. (Both gauges are set in the same code path, so there is no scrape-skew argument either way.)
Alternative 3 — accumulate delegator weight directly rather than subtracting. Workable on the load path, where the delegator iterator is already in hand, but not on the incremental path: weightChanges() has merged validator and delegator weight into a single addedWeight before updateValidatorManager sees it, so this would mean unpacking addedDelegators/deletedDelegators separately and maintaining a running total that can drift from GetWeight. The subtraction derives both numbers from the same source of truth on every update.
One implementation note worth surfacing: during genesis initialization, syncGenesis writes with updateValidators=false specifically to maintain the invariant that the validator manager is empty before initValidatorSets runs (state.go:1774-1777), while currentStakers is already populated. The subtraction is therefore only valid once the manager has caught up, and is guarded on the manager reporting non-zero local weight; otherwise the delegated value is reported as 0, matching the 0 that local_staked reports in that same window.
Open questions
- Name. local_delegated_staked keeps it in the local_* family and signals node-scope up front, which is the property total_staked lacks. delegated_staked is shorter but reads as though it could be network-wide.
- Should the network-wide delegated total also be exposed? The same confusion that makes total_staked misleading would apply in reverse if only the local value exists.The same confusion that makes total_staked misleading would apply in reverse if only the local value exists.
- Primary Network only? local_staked is Primary-Network-scoped, and this metric matches it. Whether subnet/L1 delegation deserves the same treatment (as a labeled vec, per the time_until_unstake_subnet precedent) is a separate question.
- Error handling on the commit path. updateStakeMetrics runs inside write, so an unexpected inconsistency between the validator manager and currentStakers currently fails the write rather than degrading the metric. That matches the existing convention in this file (state.go:2227-2232 returns on an impossible safemath.Sub underflow), but it does mean a metrics computation can now fail a commit — worth a reviewer's opinion on whether it should be non-fatal instead.
- Should total_staked's help text be clarified as part of this, given it is the metric operators actually misread today?
Context and scope
avalanche_platformvm_local_staked reports the weight of this node in the Primary Network validator set, which is the node's own stake plus every delegation to it, summed into a single value. The two components are merged well before the metric is emitted: AddStaker seeds the validator's own weight and each delegator is folded in via AddWeight (vms/platformvm/state/state.go:2257-2269 on load; weightChanges() at vms/platformvm/state/stakers.go:286-316 on the incremental path, whose doc comment states "The added weight includes the added validator and all added delegators"). By the time SetLocalStake is called, only the sum survives.
There is no metric that exposes the delegated portion on its own, so operators who want to track delegation to their own nodes have to poll platform.getCurrentValidators and read delegatorWeight, rather than reading it off the existing metrics pipeline. Aggregating across several nodes programmatically is also challenging and would require hand-baked scripts instead of a simple metrics query.
A related source of confusion motivates this: avalanche_platformvm_total_staked is not node-scoped, it is TotalWeight(PrimaryNetworkID), the stake of the entire network, and reads the same on every node including non-validators. Operators reasonably reach for it expecting a per-node number and get a network-wide one.
Goals:
Discussion and alternatives
Proposed: add avalanche_platformvm_local_delegated_staked, a gauge reporting the nAVAX delegated to this node on the Primary Network. It is derived at the existing metric update sites as GetWeight(PrimaryNetworkID, ctx.NodeID) - <this node's own validator weight>, with the own weight read from GetCurrentValidator. Both existing update sites (initValidatorSets and updateValidatorManager) are factored into a shared updateStakeMetrics helper so the three stake gauges are always written from one consistent snapshot of the validator manager.
Alternative 1 — a label on local_staked (e.g. source="self"|"delegated"). Rejected. local_staked is currently a plain unlabeled prometheus.Gauge; promoting it to a GaugeVec turns one series into two, so existing dashboards and threshold alerts silently change behavior with nothing to signal the break. It also forces a choice between publishing the sum and publishing the parts, you cannot have both under one metric name without a source="total" value that double-counts on aggregation. The labeled gauges already in this package (excess, price, time_until_unstake_subnet) all label over an open-ended dimension (which resource, which subnet), not a fixed two-way split of an existing scalar.
Alternative 2 — expose the node's own stake instead (local_self_staked) and let PromQL subtract. Equivalent information, but pushes arithmetic into every dashboard and alert, and reads worse for the common case. Rejected in favor of publishing the value operators actually want. (Both gauges are set in the same code path, so there is no scrape-skew argument either way.)
Alternative 3 — accumulate delegator weight directly rather than subtracting. Workable on the load path, where the delegator iterator is already in hand, but not on the incremental path: weightChanges() has merged validator and delegator weight into a single addedWeight before updateValidatorManager sees it, so this would mean unpacking addedDelegators/deletedDelegators separately and maintaining a running total that can drift from GetWeight. The subtraction derives both numbers from the same source of truth on every update.
One implementation note worth surfacing: during genesis initialization, syncGenesis writes with updateValidators=false specifically to maintain the invariant that the validator manager is empty before initValidatorSets runs (state.go:1774-1777), while currentStakers is already populated. The subtraction is therefore only valid once the manager has caught up, and is guarded on the manager reporting non-zero local weight; otherwise the delegated value is reported as 0, matching the 0 that local_staked reports in that same window.
Open questions