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 beb33127e683..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 @@ -46,6 +46,18 @@ 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 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). {@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; + /** * Logger used to write GRPC access information to a unique log file. */ @@ -242,10 +254,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 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.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 92fc5c26a3fb..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 @@ -192,6 +192,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 OTHER overflow bucket - not the number sent + assertThat(agentData).hasSize(cap + 1); + + // 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; + for (final LongAdder counter : agentData.values()) { + total += counter.sum(); + } + assertThat(total).isEqualTo(distinctAgents); + } + @ParameterizedTest @MethodSource("testTimeCalculationArgs") void testTimeCalculation(final Instant time, final Instant expectedTime) {