Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<UserAgent, LongAdder> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, UserAgentType> values = new HashMap<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserAgent, LongAdder> 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) {
Expand Down
Loading