From 56c4b953c2c2857d8d25a0a709f0cece037395d9 Mon Sep 17 00:00:00 2001 From: Alex Kehayov Date: Tue, 11 Aug 2026 12:32:39 +0300 Subject: [PATCH 1/2] fix: cap distinct user-agents per endpoint in GrpcUsageTracker Signed-off-by: Alex Kehayov --- .../app/grpc/impl/usage/GrpcUsageTracker.java | 29 ++++++++++++++--- .../grpc/impl/usage/GrpcUsageTrackerTest.java | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTracker.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTracker.java index da5183fee6a1..b53cfbc02c85 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTracker.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTracker.java @@ -46,6 +46,17 @@ public class GrpcUsageTracker implements ServerInterceptor { */ private static final int MAX_UA_LENGTH = 250; + /** + * The maximum number of distinct user-agents tracked per endpoint within a single bucket. A known SDK's version is + * a client-supplied component (any valid SemVer value is preserved as-is), so without this bound an unauthenticated + * caller could mint an unlimited number of distinct {@link UserAgent} keys within a logging interval and inflate + * heap usage. Once the limit is reached, additional distinct user-agents are folded into the {@code UNKNOWN} + * user-agent instead of adding new map entries, which keeps per-endpoint request totals accurate while capping the + * number of entries (and therefore the number of log lines emitted at flush time). + */ + @VisibleForTesting + static final int MAX_AGENTS_PER_ENDPOINT = 1000; + /** * Logger used to write GRPC access information to a unique log file. */ @@ -232,10 +243,20 @@ void recordInteraction(@NonNull final RpcEndpointName rpcEndpointName, @NonNull requireNonNull(rpcEndpointName, "rpcName is required"); requireNonNull(userAgent, "userAgent is required"); - usageData - .computeIfAbsent(rpcEndpointName, __ -> new ConcurrentHashMap<>()) - .computeIfAbsent(userAgent, __ -> new LongAdder()) - .increment(); + final ConcurrentMap usagesByAgent = + usageData.computeIfAbsent(rpcEndpointName, __ -> new ConcurrentHashMap<>()); + + // Fast path: this user-agent is already being tracked for this endpoint. + LongAdder counter = usagesByAgent.get(userAgent); + if (counter == null) { + // A new user-agent for this endpoint. Bound the number of distinct keys so that client-controlled + // user-agent values cannot grow the map without limit; any overflow is folded into UNKNOWN so the + // total request count for the endpoint is still accurate. The size() check is a best-effort bound and + // may be exceeded slightly under concurrency, which is acceptable for a safety limit. + final UserAgent key = usagesByAgent.size() >= MAX_AGENTS_PER_ENDPOINT ? UserAgent.UNKNOWN : userAgent; + counter = usagesByAgent.computeIfAbsent(key, __ -> new LongAdder()); + } + counter.increment(); } } } diff --git a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTrackerTest.java b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTrackerTest.java index 130315d15fde..c0c60fd269ba 100644 --- a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTrackerTest.java +++ b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTrackerTest.java @@ -191,6 +191,38 @@ void testLogOutput() { assertThat(usageBucket.usageData()).isEmpty(); } + @Test + void testRecordInteractionCapsDistinctUserAgentsPerEndpoint() { + final UsageBucket bucket = new UsageBucket(Instant.parse("2025-04-03T15:30:00.000Z")); + final RpcEndpointName endpoint = new RpcEndpointName("MyService", "Commit"); + + final int cap = GrpcUsageTracker.MAX_AGENTS_PER_ENDPOINT; + final int overflow = 500; + final int distinctAgents = cap + overflow; + + // Record more distinct user-agents for a single endpoint than the per-endpoint cap allows + for (int i = 0; i < distinctAgents; i++) { + bucket.recordInteraction(endpoint, new UserAgent(UserAgentType.HIERO_SDK_JAVA, "1.0." + i)); + } + + final ConcurrentMap agentData = bucket.usageData().get(endpoint); + + // Distinct keys are bounded to the cap plus the single UNKNOWN overflow bucket - not the number sent + assertThat(agentData).hasSize(cap + 1); + + // Overflow interactions beyond the cap are folded into UNKNOWN + final LongAdder unknownCounter = agentData.get(UserAgent.UNKNOWN); + assertThat(unknownCounter).isNotNull(); + assertThat(unknownCounter.sum()).isEqualTo(overflow); + + // No interactions are lost - the total count across all keys is preserved + long total = 0; + for (final LongAdder counter : agentData.values()) { + total += counter.sum(); + } + assertThat(total).isEqualTo(distinctAgents); + } + @ParameterizedTest @MethodSource("testTimeCalculationArgs") void testTimeCalculation(final Instant time, final Instant expectedTime) { From eb695f6b84c362792e18fd6d5e8a7e979ea1010a Mon Sep 17 00:00:00 2001 From: Alex Kehayov Date: Thu, 13 Aug 2026 13:05:48 +0300 Subject: [PATCH 2/2] added OTHER Signed-off-by: Alex Kehayov --- .../node/app/grpc/impl/usage/GrpcUsageTracker.java | 9 +++++---- .../com/hedera/node/app/grpc/impl/usage/UserAgent.java | 1 + .../hedera/node/app/grpc/impl/usage/UserAgentType.java | 5 ++++- .../node/app/grpc/impl/usage/GrpcUsageTrackerTest.java | 10 +++++----- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTracker.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTracker.java index 2be9a19290ae..8ec6021a0d22 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTracker.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTracker.java @@ -50,9 +50,10 @@ public class GrpcUsageTracker implements ServerInterceptor { * The maximum number of distinct user-agents tracked per endpoint within a single bucket. A known SDK's version is * a client-supplied component (any valid SemVer value is preserved as-is), so without this bound an unauthenticated * caller could mint an unlimited number of distinct {@link UserAgent} keys within a logging interval and inflate - * heap usage. Once the limit is reached, additional distinct user-agents are folded into the {@code UNKNOWN} + * heap usage. Once the limit is reached, additional distinct user-agents are folded into the {@code OTHER} * user-agent instead of adding new map entries, which keeps per-endpoint request totals accurate while capping the - * number of entries (and therefore the number of log lines emitted at flush time). + * number of entries (and therefore the number of log lines emitted at flush time). {@code OTHER} is used rather + * than {@code UNKNOWN} so that cap overflow stays distinguishable from genuinely unrecognized user-agents. */ @VisibleForTesting static final int MAX_AGENTS_PER_ENDPOINT = 1000; @@ -260,10 +261,10 @@ void recordInteraction(@NonNull final RpcEndpointName rpcEndpointName, @NonNull LongAdder counter = usagesByAgent.get(userAgent); if (counter == null) { // A new user-agent for this endpoint. Bound the number of distinct keys so that client-controlled - // user-agent values cannot grow the map without limit; any overflow is folded into UNKNOWN so the + // user-agent values cannot grow the map without limit; any overflow is folded into OTHER so the // total request count for the endpoint is still accurate. The size() check is a best-effort bound and // may be exceeded slightly under concurrency, which is acceptable for a safety limit. - final UserAgent key = usagesByAgent.size() >= MAX_AGENTS_PER_ENDPOINT ? UserAgent.UNKNOWN : userAgent; + final UserAgent key = usagesByAgent.size() >= MAX_AGENTS_PER_ENDPOINT ? UserAgent.OTHER : userAgent; counter = usagesByAgent.computeIfAbsent(key, __ -> new LongAdder()); } counter.increment(); diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/UserAgent.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/UserAgent.java index 6574214761d7..d59aa75bd410 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/UserAgent.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/UserAgent.java @@ -24,6 +24,7 @@ public record UserAgent( private static final String UNKNOWN_STR = "Unknown"; static final UserAgent UNKNOWN = new UserAgent(UserAgentType.UNKNOWN, UNKNOWN_STR); static final UserAgent UNSPECIFIED = new UserAgent(UserAgentType.UNSPECIFIED, UNKNOWN_STR); + static final UserAgent OTHER = new UserAgent(UserAgentType.OTHER, UNKNOWN_STR); private static final Logger logger = LogManager.getLogger(UserAgent.class); diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/UserAgentType.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/UserAgentType.java index 6b6d4758eb1a..49817944a8e8 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/UserAgentType.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/grpc/impl/usage/UserAgentType.java @@ -20,7 +20,10 @@ public enum UserAgentType { HIERO_SDK_RUST("HieroSdkRust", true, "hiero-sdk-rust"), HIERO_SDK_SWIFT("HieroSdkSwift", true, "hiero-sdk-swift"), UNSPECIFIED("Unspecified", false), - UNKNOWN("Unknown", false); + UNKNOWN("Unknown", false), + // Overflow bucket used when an endpoint exceeds its distinct user-agent cap. Kept distinct from UNKNOWN, which + // means a user-agent was present but unrecognized or malformed. + OTHER("Other", false); private static final Map values = new HashMap<>(); diff --git a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTrackerTest.java b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTrackerTest.java index 4b5fdf32625f..79a05d1b7ab7 100644 --- a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTrackerTest.java +++ b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/grpc/impl/usage/GrpcUsageTrackerTest.java @@ -208,13 +208,13 @@ void testRecordInteractionCapsDistinctUserAgentsPerEndpoint() { final ConcurrentMap agentData = bucket.usageData().get(endpoint); - // Distinct keys are bounded to the cap plus the single UNKNOWN overflow bucket - not the number sent + // Distinct keys are bounded to the cap plus the single OTHER overflow bucket - not the number sent assertThat(agentData).hasSize(cap + 1); - // Overflow interactions beyond the cap are folded into UNKNOWN - final LongAdder unknownCounter = agentData.get(UserAgent.UNKNOWN); - assertThat(unknownCounter).isNotNull(); - assertThat(unknownCounter.sum()).isEqualTo(overflow); + // Overflow interactions beyond the cap are folded into OTHER + final LongAdder otherCounter = agentData.get(UserAgent.OTHER); + assertThat(otherCounter).isNotNull(); + assertThat(otherCounter.sum()).isEqualTo(overflow); // No interactions are lost - the total count across all keys is preserved long total = 0;