Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4e749e8
refactor(metrics): use libp2p avg latency for fetch-block peer selection
warku123 Aug 4, 2026
a1908f5
test(metrics): add coverage for prometheus node_info, MetricsService …
warku123 Aug 4, 2026
20ee8a9
test(net): pin fetch-block failover semantics for unknown peer latency
warku123 Aug 14, 2026
e95c50c
feat(net): bound fetch-block peer latency with per-connection EWMA es…
warku123 Sep 4, 2026
6f65b8b
feat(metrics): expose node version as a prometheus info metric
warku123 Aug 4, 2026
aeee8c7
feat(metrics): add chain id to node info metric
warku123 Aug 14, 2026
bd4008d
fix(net): replace estimator seeding with direct first-sample initiali…
warku123 Sep 8, 2026
833a7af
test(net): recalculate fetch-block failover tests for unsampled read …
warku123 Sep 8, 2026
74d952e
feat(metrics): rename node_info chain_id label to genesis_block_id
warku123 Sep 8, 2026
56ef291
chore(protocol): mark legacy Monitor service and MetricsInfo as depre…
warku123 Sep 8, 2026
80740dd
chore(metrics): warn on legacy metrics stack usage at entry points
warku123 Sep 8, 2026
c8f6bf6
feat(metrics): add verification counters for fetch failover and dupli…
warku123 Sep 8, 2026
10a8f2a
chore(metrics): drop unused NET_LATENCY_FETCH_BLOCK key
warku123 Sep 8, 2026
1f1a549
chore(metrics): clarify clamp comment and add missing eof newline
warku123 Sep 8, 2026
2fe5584
feat(metrics): add armed fetch counter and rename duplicate to block_…
warku123 Sep 9, 2026
f7c9adf
refactor(metrics): extract EWMA divisor constant and add convergence …
warku123 Sep 9, 2026
b3e8dfd
fix(metrics): count requested already-known blocks by exact id
warku123 Sep 9, 2026
4443724
docs(metrics): register phase 1 metrics in changelog
warku123 Sep 22, 2026
feb287c
refactor(metrics): rename EWMA divisor to sliding factor and trim com…
warku123 Oct 5, 2026
e3a8b12
refactor(net): clarify fetch latency estimator naming
warku123 Oct 5, 2026
b24c520
Merge remote-tracking branch 'upstream/release_v4.8.3' into feature/p…
warku123 Oct 13, 2026
cb7dd42
docs(net): state blend-then-clamp choice and outlier cost in estimato…
warku123 Oct 13, 2026
ea34c85
Merge remote-tracking branch 'upstream/release_v4.8.3' into feature/p…
warku123 Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions common/src/main/java/org/tron/common/prometheus/MetricKeys.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public static class Counter {
public static final String P2P_ERROR = "tron:p2p_error";
public static final String P2P_DISCONNECT = "tron:p2p_disconnect";
public static final String INTERNAL_SERVICE_FAIL = "tron:internal_service_fail";
// verification counters for the bounded fetch latency estimator rollout
public static final String BLOCK_FETCH_ARMED = "tron:block_fetch_armed";
Comment thread
SeriousCoding789 marked this conversation as resolved.
public static final String BLOCK_FETCH_SECONDARY = "tron:block_fetch_secondary";
public static final String BLOCK_ALREADY_KNOWN = "tron:block_already_known";
Comment thread
SeriousCoding789 marked this conversation as resolved.

private Counter() {
throw new IllegalStateException("Counter");
Expand All @@ -44,6 +48,16 @@ private Gauge() {

}

// Info
public static class Info {
public static final String NODE_INFO = "tron:node";

private Info() {
throw new IllegalStateException("Info");
}

}

// Histogram
public static class Histogram {
public static final String HTTP_SERVICE_LATENCY = "tron:http_service_latency_seconds";
Expand Down
13 changes: 13 additions & 0 deletions common/src/main/java/org/tron/common/prometheus/MetricLabels.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,17 @@ private Histogram() {

}

// Info
public static class Info {
public static final String VERSION = "version";
// identifies the genesis block: the label value is the chain id derived from the
// genesis block hash, so the label is named genesis_block_id
public static final String CHAIN_ID = "genesis_block_id";

private Info() {
throw new IllegalStateException("Info");
}

}

}
4 changes: 4 additions & 0 deletions common/src/main/java/org/tron/common/prometheus/Metrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,8 @@ public static void histogramObserve(Histogram.Timer startTimer) {
public static void histogramObserve(String key, double amt, String... labels) {
MetricsHistogram.observe(key, amt, labels);
}

public static void info(String key, String... labels) {
MetricsInfo.set(key, labels);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ class MetricsCounter {
init(MetricKeys.Counter.P2P_DISCONNECT, "tron p2p disconnect .", "type");
init(MetricKeys.Counter.INTERNAL_SERVICE_FAIL, "internal Service fail.",
"class", "method");
init(MetricKeys.Counter.BLOCK_FETCH_ARMED,
"in-flight fetch requests armed by the fetch-block service.");
init(MetricKeys.Counter.BLOCK_FETCH_SECONDARY,
"secondary fetch requests issued by the fetch-block failover estimator.");
init(MetricKeys.Counter.BLOCK_ALREADY_KNOWN,
"adv block responses matched to an outstanding request whose exact block id "
+ "was already known when the response was handled (best-effort: concurrent "
+ "arrivals may be missed; a duplicate is not attributed to secondary "
+ "fetches).");
}

private MetricsCounter() {
Expand Down
40 changes: 40 additions & 0 deletions common/src/main/java/org/tron/common/prometheus/MetricsInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.tron.common.prometheus;

import io.prometheus.client.Info;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "metrics")
class MetricsInfo {

private static final Map<String, Info> container = new ConcurrentHashMap<>();

static {
init(MetricKeys.Info.NODE_INFO, "tron node info.",
MetricLabels.Info.VERSION, MetricLabels.Info.CHAIN_ID);
}

private MetricsInfo() {
throw new IllegalStateException("MetricsInfo");
}

private static void init(String name, String help, String... labels) {
container.put(name, Info.build()
.name(name)
.help(help)
.labelNames(labels)
.register());
}

static void set(String key, String... labels) {
if (Metrics.enabled()) {
Info info = container.get(key);
if (info == null) {
logger.info("{} not exist", key);
return;
}
info.labels(labels);
}
}
}
11 changes: 11 additions & 0 deletions docs/metrics-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ Metrics Changelog

This file tracks Prometheus metric additions, changes, and removals in java-tron. For the full set of metrics emitted today, see the references at the bottom.

**4.8.3**

### New Metrics

#### Core

- `tron:node_info` (Info, labels `version`, `genesis_block_id`) — static node identity: the running node version string plus the full genesis block hash as the canonical chain identifier, for fleet dashboards and version/chain alert rules. ([#6923](https://github.com/tronprotocol/java-tron/issues/6923))
- `tron:block_fetch_armed` (Counter) — incremented when `FetchBlockService` arms a fetch tracking for a block; denominator for secondary-fetch rates. ([#6923](https://github.com/tronprotocol/java-tron/issues/6923))
- `tron:block_fetch_secondary` (Counter) — incremented when a secondary fetch request is sent to an alternate peer. ([#6923](https://github.com/tronprotocol/java-tron/issues/6923))
- `tron:block_already_known` (Counter) — incremented when a block response matches an outstanding adv request whose exact block ID is already known before that response is processed (best-effort signal; concurrent arrivals may be missed). ([#6923](https://github.com/tronprotocol/java-tron/issues/6923))

**4.8.2**

### New Metrics
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,5 @@ public class MetricsKey {
public static final String NET_API_DETAIL_QPS = "net.api.detail.qps.";
public static final String NET_API_DETAIL_FAIL_QPS = "net.api.detail.failQps.";
public static final String NET_API_DETAIL_OUT_TRAFFIC = "net.api.detail.outTraffic.";
public static final String NET_LATENCY_FETCH_BLOCK = "net.latency.fetch.block.";

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
import org.tron.core.config.args.Args;
import org.tron.core.exception.P2pException;
import org.tron.core.exception.P2pException.TypeEnum;
import org.tron.core.metrics.MetricsKey;
import org.tron.core.metrics.MetricsUtil;
import org.tron.core.net.TronNetDelegate;
import org.tron.core.net.message.TronMessage;
import org.tron.core.net.message.adv.BlockMessage;
Expand Down Expand Up @@ -91,10 +89,19 @@ public void processMessage(PeerConnection peer, TronMessage msg) throws P2pExcep
}
Long time = peer.getAdvInvRequest().remove(item);
if (null != time) {
MetricsUtil.histogramUpdateUnCheck(MetricsKey.NET_LATENCY_FETCH_BLOCK
+ peer.getInetAddress(), now - time);
peer.updateFetchLatency(now - time);
Metrics.histogramObserve(MetricKeys.Histogram.BLOCK_FETCH_LATENCY,
(now - time) / Metrics.MILLISECONDS_PER_SECOND);
// Best-effort duplicate signal: only responses matched to an outstanding adv
// request whose exact block id was already known before this response is
// processed (a concurrent or redundant arrival) are counted. The lookup is
// exact-id (block store + khaos), not a height comparison: an unknown fork
// block below head must not count. Concurrency can still let a simultaneous
// arrival slip through, and a duplicate is not attributed to a secondary
// fetch, so this is a lower-bound indicator rather than an exact count.
if (tronNetDelegate.containBlock(blockId)) {
Metrics.counterInc(MetricKeys.Counter.BLOCK_ALREADY_KNOWN, 1);
}
}
Metrics.histogramObserve(MetricKeys.Histogram.BLOCK_RECEIVE_DELAY,
(now - blockMessage.getBlockCapsule().getTimeStamp()) / Metrics.MILLISECONDS_PER_SECOND);
Expand Down
60 changes: 60 additions & 0 deletions framework/src/main/java/org/tron/core/net/peer/PeerConnection.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.tron.common.math.StrictMathWrapper;
import org.tron.common.overlay.message.Message;
import org.tron.common.parameter.CommonParameter;
import org.tron.common.prometheus.MetricKeys;
import org.tron.common.prometheus.Metrics;
import org.tron.common.utils.Pair;
Expand Down Expand Up @@ -92,6 +94,25 @@ public class PeerConnection {
@Getter
private volatile long blockRcvTime;

/**
* Sliding factor of the fetch latency Exponentially Weighted Moving Average (EWMA):
* each new sample contributes
* 1 / FETCH_LATENCY_EWMA_FACTOR (alpha = 0.1), balancing smoothing against
* responsiveness, in the same order as TCP's SRTT gain (1/8, RFC 6298).
*
* <p>Blend-then-clamp is intentional: samples blend at their measured value and are
* clamped to fetchBlock.timeout only afterwards, so sustained degradation saturates
* within 1-3 samples (issue #6923). Accepted cost: one transient outlier (~10x a
* healthy estimate) can jump the estimate straight to the clamp, needing ~9 healthy
* samples to decay; hard-timeout fallback and the saturation gate (one secondary
* fetch per tick) bound the impact meanwhile.
*/
private static final int FETCH_LATENCY_EWMA_FACTOR = 10;

private volatile long fetchLatency;

private volatile boolean hasFetchLatencySampled;

@Getter
@Setter
private volatile TronState tronState = TronState.INIT;
Expand Down Expand Up @@ -184,6 +205,45 @@ public void setChannel(Channel channel) {
Args.getInstance().getRateLimiterDisconnect());
}

/**
* Updates the fetch latency estimator, which has an explicit unsampled state: the
* channel's average latency is only a read fallback and never enters the sample sequence.
* The first measured sample directly replaces the placeholder; subsequent samples are
* blended with alpha = 1 / FETCH_LATENCY_EWMA_FACTOR. A single fetch worker reads while
* the channel event loop writes, so volatile suffices and no lock should be added.
* Clamping applies only after blending, by design (see {@link #FETCH_LATENCY_EWMA_FACTOR}).
*
* @param latencyMillis measured fetch latency in milliseconds
*/
public void updateFetchLatency(long latencyMillis) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[DISCUSS] A single transient outlier can saturate a warmed estimate

The sample is blended before clamping, so one slow response can push a warmed estimate straight to the clamp. #6923 measured this (oldMs=6, sampleMs=2003, newMs=200, #issuecomment-5996678986), but only as responsiveness under sustained degradation. The transient case wasn't covered there: one slow sample, then the peer is healthy again.

Example with timeout = 500 and a warmed estimate of 100 ms: any single sample ≥ 4100 ms saturates. Samples can be up to ADV_TIME_OUT = 20 s before PeerStatusCheck disconnects. Decaying back below 250 ms then takes ~9 healthy samples. During that time:

  • if this peer is the old peer, the saturation gate dispatches a secondary on the next 50 ms tick;
  • if it is a candidate, it ranks last.

The old P75 over a ~1028-sample decaying reservoir was essentially insensitive to a single outlier.

This costs at most some extra secondaries and duplicate downloads, not correctness, so it may well be acceptable. If not, a one-line change caps any single sample's influence at timeout / 10:

fetchLatency = clampFetchLatency(
    (fetchLatency * (FETCH_LATENCY_EWMA_FACTOR - 1) + clampFetchLatency(latencyMillis))
        / FETCH_LATENCY_EWMA_FACTOR);

Either way, it would help to state the choice in the Javadoc next to the α rationale.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for looking at this — you're right that the transient-outlier case is not covered by the #6923 runs, and the arithmetic checks out: with the blend applied before the clamp, a single sample of ≥ ~10× a healthy estimate moves the estimate straight to the clamp, and at α = 0.1 it takes about nine healthy samples to decay back (≈27 s at the 3 s block cadence).

We kept blend-then-clamp deliberately. Clamping the sample instead of the estimate caps each sample's contribution, so under sustained degradation the estimate would approach the clamp only geometrically — roughly 20+ samples versus the measured 1–3 — which directly weakens the saturation-gate switching that motivated this change, and the convergence behavior pinned in #6923. The transient cost is bounded as you note: while the estimate is saturated, the worst case is one secondary dispatch per 50 ms worker tick, every response still feeds the estimator a real measured sample, hard timeout remains the unconditional backstop, and the ranking self-heals within ~9 samples.

So we prefer keeping the current semantics; the choice, its rationale, and the transient-outlier cost are now stated in the estimator javadoc (cb7dd42). Happy to revisit sample-side clamping if you still prefer it.

if (!hasFetchLatencySampled) {
fetchLatency = clampFetchLatency(latencyMillis);
hasFetchLatencySampled = true;
} else {
fetchLatency = clampFetchLatency(
(fetchLatency * (FETCH_LATENCY_EWMA_FACTOR - 1) + latencyMillis)
/ FETCH_LATENCY_EWMA_FACTOR);
}
}

/**
* Returns the bounded fetch latency estimate. While the estimator has not observed a
* real fetch sample yet, the channel's average latency is returned as a read fallback
* (an unknown peer is treated via its transport-level estimate instead of 0).
*/
public long getFetchLatency() {
if (!hasFetchLatencySampled) {
return channel.getAvgLatency();
}
return fetchLatency;
}

private long clampFetchLatency(long latency) {
// Saturation intentionally makes the >= timeout gate in FetchBlockService trigger.
return StrictMathWrapper.max(0,
StrictMathWrapper.min(CommonParameter.getInstance().fetchBlockTimeout, latency));
}

public void setBlockBothHave(BlockId blockId) {
this.blockBothHave = blockId;
this.blockBothHaveUpdateTime = System.currentTimeMillis();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@
import org.springframework.stereotype.Component;
import org.tron.common.es.ExecutorServiceManager;
import org.tron.common.parameter.CommonParameter;
import org.tron.common.prometheus.MetricKeys;
import org.tron.common.prometheus.Metrics;
import org.tron.common.utils.Sha256Hash;
import org.tron.core.ChainBaseManager;
import org.tron.core.capsule.BlockCapsule;
import org.tron.core.metrics.MetricsKey;
import org.tron.core.metrics.MetricsUtil;
import org.tron.core.net.TronNetDelegate;
import org.tron.core.net.message.adv.FetchInvDataMessage;
import org.tron.core.net.peer.Item;
Expand Down Expand Up @@ -73,6 +73,7 @@ public void fetchBlock(List<Sha256Hash> sha256HashList, PeerConnection peer) {
.findFirst().ifPresent(sha256Hash -> {
long now = System.currentTimeMillis();
fetchBlockInfo = new FetchBlockInfo(sha256Hash, peer, now);
Metrics.counterInc(MetricKeys.Counter.BLOCK_FETCH_ARMED, 1);
logger.info("Set fetchBlockInfo, block: {}, peer: {}, time: {}", sha256Hash,
peer.getInetAddress(), now);
});
Expand All @@ -97,16 +98,17 @@ private void fetchBlockProcess(FetchBlockInfo fetchBlock) {
.filter(PeerConnection::isIdle)
.filter(filterPeer -> !filterPeer.equals(fetchBlock.getPeer()))
.filter(filterPeer -> filterPeer.getAdvInvReceive().getIfPresent(item) != null)
.filter(filterPeer -> getPeerTop75(filterPeer)
<= CommonParameter.getInstance().fetchBlockTimeout)
.min(Comparator.comparingDouble(this::getPeerTop75));
// Seeded estimates are clamped to the fetch timeout; the unseeded channel-latency
// fallback is not, but min() ordering and the saturation gate keep it safe.
.min(Comparator.comparingDouble(this::getPeerLatency));

if (optionalPeerConnection.isPresent()) {
optionalPeerConnection.ifPresent(firstPeer -> {
if (shouldFetchBlock(firstPeer, fetchBlock)
&& firstPeer.checkAndPutAdvInvRequest(item, System.currentTimeMillis())) {
firstPeer.sendMessage(new FetchInvDataMessage(Collections.singletonList(item.getHash()),
item.getType()));
Metrics.counterInc(MetricKeys.Counter.BLOCK_FETCH_SECONDARY, 1);
this.fetchBlockInfo = null;
}
});
Expand All @@ -120,21 +122,27 @@ private void fetchBlockProcess(FetchBlockInfo fetchBlock) {
}

private boolean shouldFetchBlock(PeerConnection newPeer, FetchBlockInfo fetchBlock) {
double newPeerTop75 = getPeerTop75(newPeer);
double oldPeerTop75 = getPeerTop75(fetchBlock.getPeer());
double newPeerLatency = getPeerLatency(newPeer);
double oldPeerLatency = getPeerLatency(fetchBlock.getPeer());
long oldPeerSpendTime = System.currentTimeMillis() - fetchBlock.getTime();
if (oldPeerTop75 > fetchTimeOut || oldPeerSpendTime >= fetchTimeOut) {
// Switch unconditionally on a hard timeout: an unseeded or saturated old peer must not
// permanently wedge fetchBlockInfo.
if (oldPeerSpendTime >= fetchTimeOut) {
return true;
}

double oldPeerLeftTime = oldPeerTop75 - oldPeerSpendTime;
return newPeerTop75 < oldPeerLeftTime * BLOCK_FETCH_LEFT_TIME_PERCENT
&& oldPeerSpendTime + newPeerTop75 < fetchTimeOut;
// Require a strictly better peer for the latency saturation gate to prevent 500v500 flapping.
if (oldPeerLatency >= fetchTimeOut && newPeerLatency < oldPeerLatency) {
return true;
}

double oldPeerLeftTime = oldPeerLatency - oldPeerSpendTime;
return newPeerLatency < oldPeerLeftTime * BLOCK_FETCH_LEFT_TIME_PERCENT
&& oldPeerSpendTime + newPeerLatency < fetchTimeOut;
}

private double getPeerTop75(PeerConnection peerConnection) {
return MetricsUtil.getHistogram(MetricsKey.NET_LATENCY_FETCH_BLOCK
+ peerConnection.getInetAddress()).getSnapshot().get75thPercentile();
private double getPeerLatency(PeerConnection peerConnection) {
return peerConnection.getFetchLatency();
}

private static class FetchBlockInfo {
Expand All @@ -159,4 +167,4 @@ public FetchBlockInfo(Sha256Hash hash, PeerConnection peer, long time) {

}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.grpc.netty.NettyServerBuilder;
import io.grpc.stub.StreamObserver;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
Expand Down Expand Up @@ -2375,9 +2376,15 @@ public void getBlock(GrpcAPI.BlockReq request,

public class MonitorApi extends MonitorGrpc.MonitorImplBase {

private final AtomicBoolean deprecatedWarned = new AtomicBoolean(false);

@Override
public void getStatsInfo(EmptyMessage request,
StreamObserver<Protocol.MetricsInfo> responseObserver) {
if (deprecatedWarned.compareAndSet(false, true)) {
logger.warn("rpc Monitor.GetStatsInfo is deprecated and will be removed in a "
+ "future major release; migrate to the prometheus metrics endpoint");
}
responseObserver.onNext(metricsApiService.getMetricProtoInfo());
responseObserver.onCompleted();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.tron.core.services.http.servlets;

import java.util.concurrent.atomic.AtomicBoolean;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -15,10 +16,16 @@
@HttpApiExcluded("root-mounted at /monitor/getstatsinfo, not a wallet endpoint")
public class MetricsServlet extends RateLimiterServlet {

private static final AtomicBoolean deprecatedWarned = new AtomicBoolean(false);

@Autowired
private MetricsApiService metricsApiService;

protected void doGet(HttpServletRequest request, HttpServletResponse response) {
if (deprecatedWarned.compareAndSet(false, true)) {
logger.warn("HTTP /monitor/getstatsinfo is deprecated and will be removed in a "
+ "future major release; migrate to the prometheus metrics endpoint");
}
try {
MetricsInfo metricsInfo = metricsApiService.getMetricsInfo();

Expand Down
Loading
Loading