From f7395c514ebe96b8a8c6f6f03b85ad9bb5b4f27c Mon Sep 17 00:00:00 2001 From: vahid torkaman Date: Mon, 13 Oct 2025 09:57:32 +0200 Subject: [PATCH 1/3] chunk down flag assigned to not hit the max grpc limit --- .../confidence/GrpcWasmFlagLogger.java | 80 ++++++++++++++++++- .../LocalResolverServiceFactory.java | 12 ++- .../ThreadLocalSwapWasmResolverApi.java | 1 + .../spotify/confidence/WasmResolveApi.java | 3 +- .../java/com/spotify/confidence/TestBase.java | 12 ++- 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java index a2508fc8..86c416fb 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java @@ -8,14 +8,21 @@ import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class GrpcWasmFlagLogger implements WasmFlagLogger { private static final String CONFIDENCE_DOMAIN = "edge-grpc.spotify.com"; private static final Logger logger = LoggerFactory.getLogger(GrpcWasmFlagLogger.class); + // Max number of flag_assigned entries per chunk to avoid exceeding gRPC max message size + private static final int MAX_FLAG_ASSIGNED_PER_CHUNK = 1000; private final InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub stub; + private final ExecutorService executorService; public GrpcWasmFlagLogger(ApiSecret apiSecret) { final var channel = createConfidenceChannel(); @@ -26,15 +33,84 @@ public GrpcWasmFlagLogger(ApiSecret apiSecret) { final Channel authenticatedChannel = ClientInterceptors.intercept(channel, new JwtAuthClientInterceptor(tokenHolder)); this.stub = InternalFlagLoggerServiceGrpc.newBlockingStub(authenticatedChannel); + this.executorService = Executors.newCachedThreadPool(); } @Override public void write(WriteFlagLogsRequest request) { - if (request.getClientResolveInfoList().isEmpty() && request.getFlagAssignedList().isEmpty()) { + if (request.getClientResolveInfoList().isEmpty() + && request.getFlagAssignedList().isEmpty() + && request.getFlagResolveInfoList().isEmpty()) { logger.debug("Skipping empty flag log request"); return; } - final var ignore = stub.writeFlagLogs(request); + + final int flagAssignedCount = request.getFlagAssignedCount(); + + // If flag_assigned list is small enough, send everything as-is + if (flagAssignedCount <= MAX_FLAG_ASSIGNED_PER_CHUNK) { + sendAsync(request); + return; + } + + // Split flag_assigned into chunks and send each chunk asynchronously + logger.debug( + "Splitting {} flag_assigned entries into chunks of {}", + flagAssignedCount, + MAX_FLAG_ASSIGNED_PER_CHUNK); + + final List chunks = createFlagAssignedChunks(request); + for (WriteFlagLogsRequest chunk : chunks) { + sendAsync(chunk); + } + } + + private List createFlagAssignedChunks(WriteFlagLogsRequest request) { + final List chunks = new ArrayList<>(); + final int totalFlags = request.getFlagAssignedCount(); + + for (int i = 0; i < totalFlags; i += MAX_FLAG_ASSIGNED_PER_CHUNK) { + final int end = Math.min(i + MAX_FLAG_ASSIGNED_PER_CHUNK, totalFlags); + final WriteFlagLogsRequest.Builder chunkBuilder = + WriteFlagLogsRequest.newBuilder() + .addAllFlagAssigned(request.getFlagAssignedList().subList(i, end)); + + // Include metadata only in the first chunk + if (i == 0) { + if (request.hasTelemetryData()) { + chunkBuilder.setTelemetryData(request.getTelemetryData()); + } + chunkBuilder + .addAllClientResolveInfo(request.getClientResolveInfoList()) + .addAllFlagResolveInfo(request.getFlagResolveInfoList()); + } + + chunks.add(chunkBuilder.build()); + } + + return chunks; + } + + private void sendAsync(WriteFlagLogsRequest request) { + executorService.submit( + () -> { + try { + stub.writeFlagLogs(request); + logger.debug( + "Successfully sent flag log with {} entries", request.getFlagAssignedCount()); + } catch (Exception e) { + logger.error("Failed to write flag logs", e); + } + }); + } + + /** + * Shutdown the executor service. This will allow any pending async writes to complete. Call this + * when the application is shutting down. + */ + @Override + public void shutdown() { + executorService.shutdown(); } private static ManagedChannel createConfidenceChannel() { diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java index 802507ce..6377bea4 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/LocalResolverServiceFactory.java @@ -10,7 +10,7 @@ import com.spotify.confidence.shaded.flags.admin.v1.ResolverStateServiceGrpc.ResolverStateServiceBlockingStub; import com.spotify.confidence.shaded.flags.resolver.v1.InternalFlagLoggerServiceGrpc; import com.spotify.confidence.shaded.flags.resolver.v1.Sdk; -import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsResponse; +import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; import com.spotify.confidence.shaded.iam.v1.AuthServiceGrpc; import com.spotify.confidence.shaded.iam.v1.AuthServiceGrpc.AuthServiceBlockingStub; import com.spotify.confidence.shaded.iam.v1.ClientCredential.ClientSecret; @@ -179,7 +179,15 @@ private static FlagResolverService createFlagResolverService( .orElse(Duration.ofMinutes(5).toSeconds()); final AtomicReference resolverStateProtobuf = new AtomicReference<>(accountStateProvider.provide()); - final WasmFlagLogger flagLogger = request -> WriteFlagLogsResponse.getDefaultInstance(); + // No-op logger for wasm mode with AccountStateProvider + final WasmFlagLogger flagLogger = + new WasmFlagLogger() { + @Override + public void write(WriteFlagLogsRequest request) {} + + @Override + public void shutdown() {} + }; final ResolverApi wasmResolverApi = new ThreadLocalSwapWasmResolverApi( flagLogger, resolverStateProtobuf.get(), accountId, stickyResolveStrategy); diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/ThreadLocalSwapWasmResolverApi.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/ThreadLocalSwapWasmResolverApi.java index dd10bbf7..d707f1d0 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/ThreadLocalSwapWasmResolverApi.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/ThreadLocalSwapWasmResolverApi.java @@ -121,6 +121,7 @@ public int getInstanceCount() { @Override public void close() { resolverInstances.values().forEach(SwapWasmResolverApi::close); + flagLogger.shutdown(); resolverInstances.clear(); } } diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmResolveApi.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmResolveApi.java index 0e20a3b0..30b3e47a 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmResolveApi.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/WasmResolveApi.java @@ -31,9 +31,10 @@ class IsClosedException extends Exception {} -@FunctionalInterface interface WasmFlagLogger { void write(WriteFlagLogsRequest request); + + void shutdown(); } class WasmResolveApi { diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java index 9687f72c..b881dba1 100644 --- a/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java +++ b/openfeature-provider-local/src/test/java/com/spotify/confidence/TestBase.java @@ -7,6 +7,7 @@ import com.google.protobuf.util.Values; import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsRequest; import com.spotify.confidence.shaded.flags.resolver.v1.ResolveFlagsResponse; +import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; import com.spotify.confidence.shaded.iam.v1.Client; import com.spotify.confidence.shaded.iam.v1.ClientCredential; import java.util.BitSet; @@ -45,7 +46,16 @@ protected TestBase(ResolverState state, boolean isWasm) { if (isWasm) { final var wasmResolverApi = new SwapWasmResolverApi( - request -> {}, desiredState.toProto().toByteArray(), "", mockFallback); + new WasmFlagLogger() { + @Override + public void write(WriteFlagLogsRequest request) {} + + @Override + public void shutdown() {} + }, + desiredState.toProto().toByteArray(), + "", + mockFallback); resolverServiceFactory = new LocalResolverServiceFactory( wasmResolverApi, resolverState, resolveTokenConverter, mock(), mockFallback); From 1a954f8b5a06c8bcaab01a73fc93a9fbc1ce8dfc Mon Sep 17 00:00:00 2001 From: vahid torkaman Date: Mon, 13 Oct 2025 11:43:08 +0200 Subject: [PATCH 2/3] write test --- .../confidence/GrpcWasmFlagLogger.java | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java index 86c416fb..e04dc7c5 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java @@ -1,5 +1,6 @@ package com.spotify.confidence; +import com.google.common.annotations.VisibleForTesting; import com.spotify.confidence.shaded.flags.resolver.v1.InternalFlagLoggerServiceGrpc; import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; import com.spotify.confidence.shaded.iam.v1.AuthServiceGrpc; @@ -16,6 +17,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +@FunctionalInterface +interface FlagLogWriter { + void write(WriteFlagLogsRequest request); +} + public class GrpcWasmFlagLogger implements WasmFlagLogger { private static final String CONFIDENCE_DOMAIN = "edge-grpc.spotify.com"; private static final Logger logger = LoggerFactory.getLogger(GrpcWasmFlagLogger.class); @@ -23,6 +29,21 @@ public class GrpcWasmFlagLogger implements WasmFlagLogger { private static final int MAX_FLAG_ASSIGNED_PER_CHUNK = 1000; private final InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub stub; private final ExecutorService executorService; + private final FlagLogWriter writer; + + @VisibleForTesting + public GrpcWasmFlagLogger(ApiSecret apiSecret, FlagLogWriter writer) { + final var channel = createConfidenceChannel(); + final AuthServiceGrpc.AuthServiceBlockingStub authService = + AuthServiceGrpc.newBlockingStub(channel); + final TokenHolder tokenHolder = + new TokenHolder(apiSecret.clientId(), apiSecret.clientSecret(), authService); + final Channel authenticatedChannel = + ClientInterceptors.intercept(channel, new JwtAuthClientInterceptor(tokenHolder)); + this.stub = InternalFlagLoggerServiceGrpc.newBlockingStub(authenticatedChannel); + this.executorService = Executors.newCachedThreadPool(); + this.writer = writer; + } public GrpcWasmFlagLogger(ApiSecret apiSecret) { final var channel = createConfidenceChannel(); @@ -34,6 +55,19 @@ public GrpcWasmFlagLogger(ApiSecret apiSecret) { ClientInterceptors.intercept(channel, new JwtAuthClientInterceptor(tokenHolder)); this.stub = InternalFlagLoggerServiceGrpc.newBlockingStub(authenticatedChannel); this.executorService = Executors.newCachedThreadPool(); + this.writer = + request -> + executorService.submit( + () -> { + try { + final var ignore = stub.writeFlagLogs(request); + logger.debug( + "Successfully sent flag log with {} entries", + request.getFlagAssignedCount()); + } catch (Exception e) { + logger.error("Failed to write flag logs", e); + } + }); } @Override @@ -92,16 +126,7 @@ private List createFlagAssignedChunks(WriteFlagLogsRequest } private void sendAsync(WriteFlagLogsRequest request) { - executorService.submit( - () -> { - try { - stub.writeFlagLogs(request); - logger.debug( - "Successfully sent flag log with {} entries", request.getFlagAssignedCount()); - } catch (Exception e) { - logger.error("Failed to write flag logs", e); - } - }); + writer.write(request); } /** From 987531bc7897170bf425c09e9703b8c8311d0652 Mon Sep 17 00:00:00 2001 From: vahid torkaman Date: Mon, 13 Oct 2025 11:45:39 +0200 Subject: [PATCH 3/3] fixup! write test --- .../confidence/GrpcWasmFlagLogger.java | 2 +- .../confidence/GrpcWasmFlagLoggerTest.java | 235 ++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 openfeature-provider-local/src/test/java/com/spotify/confidence/GrpcWasmFlagLoggerTest.java diff --git a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java b/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java index e04dc7c5..fcc3e1ec 100644 --- a/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java +++ b/openfeature-provider-local/src/main/java/com/spotify/confidence/GrpcWasmFlagLogger.java @@ -109,7 +109,7 @@ private List createFlagAssignedChunks(WriteFlagLogsRequest WriteFlagLogsRequest.newBuilder() .addAllFlagAssigned(request.getFlagAssignedList().subList(i, end)); - // Include metadata only in the first chunk + // Include telemetry and resolve info only in the first chunk if (i == 0) { if (request.hasTelemetryData()) { chunkBuilder.setTelemetryData(request.getTelemetryData()); diff --git a/openfeature-provider-local/src/test/java/com/spotify/confidence/GrpcWasmFlagLoggerTest.java b/openfeature-provider-local/src/test/java/com/spotify/confidence/GrpcWasmFlagLoggerTest.java new file mode 100644 index 00000000..e1eb9fab --- /dev/null +++ b/openfeature-provider-local/src/test/java/com/spotify/confidence/GrpcWasmFlagLoggerTest.java @@ -0,0 +1,235 @@ +package com.spotify.confidence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.spotify.confidence.shaded.flags.admin.v1.ClientResolveInfo; +import com.spotify.confidence.shaded.flags.admin.v1.FlagResolveInfo; +import com.spotify.confidence.shaded.flags.resolver.v1.InternalFlagLoggerServiceGrpc; +import com.spotify.confidence.shaded.flags.resolver.v1.TelemetryData; +import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsRequest; +import com.spotify.confidence.shaded.flags.resolver.v1.WriteFlagLogsResponse; +import com.spotify.confidence.shaded.flags.resolver.v1.events.ClientInfo; +import com.spotify.confidence.shaded.flags.resolver.v1.events.FlagAssigned; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class GrpcWasmFlagLoggerTest { + + @Test + void testEmptyRequest_shouldSkip() { + // Given + final var mockStub = + mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); + final var logger = createLoggerWithMockStub(mockStub); + final var emptyRequest = WriteFlagLogsRequest.newBuilder().build(); + + // When + logger.write(emptyRequest); + + // Then + verify(mockStub, never()).writeFlagLogs(any()); + logger.shutdown(); + } + + @Test + void testSmallRequest_shouldSendAsIs() { + // Given + final var mockStub = + mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); + when(mockStub.writeFlagLogs(any())).thenReturn(WriteFlagLogsResponse.getDefaultInstance()); + final var logger = createLoggerWithMockStub(mockStub); + + final var request = + WriteFlagLogsRequest.newBuilder() + .addAllFlagAssigned(createFlagAssignedList(100)) + .setTelemetryData(TelemetryData.newBuilder().setDroppedEvents(5).build()) + .addClientResolveInfo( + ClientResolveInfo.newBuilder().setClient("clients/test-client").build()) + .addFlagResolveInfo(FlagResolveInfo.newBuilder().setFlag("flags/test-flag").build()) + .build(); + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(WriteFlagLogsRequest.class); + + // When + logger.write(request); + + // Then + verify(mockStub, times(1)).writeFlagLogs(captor.capture()); + + final WriteFlagLogsRequest sentRequest = captor.getValue(); + assertEquals(100, sentRequest.getFlagAssignedCount()); + assertEquals(5, sentRequest.getTelemetryData().getDroppedEvents()); + assertEquals(1, sentRequest.getClientResolveInfoCount()); + assertEquals(1, sentRequest.getFlagResolveInfoCount()); + + logger.shutdown(); + } + + @Test + void testLargeRequest_shouldChunkWithMetadataInFirstChunkOnly() { + // Given + final var mockStub = + mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); + when(mockStub.writeFlagLogs(any())).thenReturn(WriteFlagLogsResponse.getDefaultInstance()); + final var logger = createLoggerWithMockStub(mockStub); + + final int totalFlags = 2500; // Will create 3 chunks: 1000, 1000, 500 + final var request = + WriteFlagLogsRequest.newBuilder() + .addAllFlagAssigned(createFlagAssignedList(totalFlags)) + .setTelemetryData(TelemetryData.newBuilder().setDroppedEvents(10).build()) + .addClientResolveInfo( + ClientResolveInfo.newBuilder().setClient("clients/test-client").build()) + .addFlagResolveInfo(FlagResolveInfo.newBuilder().setFlag("flags/test-flag").build()) + .build(); + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(WriteFlagLogsRequest.class); + + // When + logger.write(request); + + // Then + verify(mockStub, times(3)).writeFlagLogs(captor.capture()); + + final List sentRequests = captor.getAllValues(); + assertEquals(3, sentRequests.size()); + + // First chunk: 1000 flag_assigned + metadata + final WriteFlagLogsRequest firstChunk = sentRequests.get(0); + assertEquals(1000, firstChunk.getFlagAssignedCount()); + assertTrue(firstChunk.hasTelemetryData()); + assertEquals(10, firstChunk.getTelemetryData().getDroppedEvents()); + assertEquals(1, firstChunk.getClientResolveInfoCount()); + assertEquals("clients/test-client", firstChunk.getClientResolveInfo(0).getClient()); + assertEquals(1, firstChunk.getFlagResolveInfoCount()); + assertEquals("flags/test-flag", firstChunk.getFlagResolveInfo(0).getFlag()); + + // Second chunk: 1000 flag_assigned only, no metadata + final WriteFlagLogsRequest secondChunk = sentRequests.get(1); + assertEquals(1000, secondChunk.getFlagAssignedCount()); + assertEquals(false, secondChunk.hasTelemetryData()); + assertEquals(0, secondChunk.getClientResolveInfoCount()); + assertEquals(0, secondChunk.getFlagResolveInfoCount()); + + // Third chunk: 500 flag_assigned only, no metadata + final WriteFlagLogsRequest thirdChunk = sentRequests.get(2); + assertEquals(500, thirdChunk.getFlagAssignedCount()); + assertEquals(false, thirdChunk.hasTelemetryData()); + assertEquals(0, thirdChunk.getClientResolveInfoCount()); + assertEquals(0, thirdChunk.getFlagResolveInfoCount()); + + logger.shutdown(); + } + + @Test + void testExactlyAtChunkBoundary_shouldCreateTwoChunks() { + // Given + final var mockStub = + mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); + when(mockStub.writeFlagLogs(any())).thenReturn(WriteFlagLogsResponse.getDefaultInstance()); + final var logger = createLoggerWithMockStub(mockStub); + + final int totalFlags = 2000; // Exactly 2 chunks of 1000 + final var request = + WriteFlagLogsRequest.newBuilder() + .addAllFlagAssigned(createFlagAssignedList(totalFlags)) + .setTelemetryData(TelemetryData.newBuilder().setDroppedEvents(7).build()) + .build(); + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(WriteFlagLogsRequest.class); + + // When + logger.write(request); + + // Then + verify(mockStub, times(2)).writeFlagLogs(captor.capture()); + + final List sentRequests = captor.getAllValues(); + assertEquals(2, sentRequests.size()); + + // First chunk with metadata + assertEquals(1000, sentRequests.get(0).getFlagAssignedCount()); + assertTrue(sentRequests.get(0).hasTelemetryData()); + + // Second chunk without metadata + assertEquals(1000, sentRequests.get(1).getFlagAssignedCount()); + assertEquals(false, sentRequests.get(1).hasTelemetryData()); + + logger.shutdown(); + } + + @Test + void testOnlyMetadata_noFlagAssigned_shouldSendAsIs() { + // Given + final var mockStub = + mock(InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub.class); + when(mockStub.writeFlagLogs(any())).thenReturn(WriteFlagLogsResponse.getDefaultInstance()); + final var logger = createLoggerWithMockStub(mockStub); + + final var request = + WriteFlagLogsRequest.newBuilder() + .setTelemetryData(TelemetryData.newBuilder().setDroppedEvents(3).build()) + .addClientResolveInfo( + ClientResolveInfo.newBuilder().setClient("clients/test-client").build()) + .build(); + + final ArgumentCaptor captor = + ArgumentCaptor.forClass(WriteFlagLogsRequest.class); + + // When + logger.write(request); + + // Then + verify(mockStub, times(1)).writeFlagLogs(captor.capture()); + + final WriteFlagLogsRequest sentRequest = captor.getValue(); + assertEquals(0, sentRequest.getFlagAssignedCount()); + assertTrue(sentRequest.hasTelemetryData()); + assertEquals(1, sentRequest.getClientResolveInfoCount()); + + logger.shutdown(); + } + + // Helper methods + + private List createFlagAssignedList(int count) { + final List list = new ArrayList<>(); + for (int i = 0; i < count; i++) { + list.add( + FlagAssigned.newBuilder() + .setResolveId("resolve-" + i) + .setClientInfo( + ClientInfo.newBuilder() + .setClient("clients/test-client") + .setClientCredential("clients/test-client/credentials/cred-1") + .build()) + .addFlags( + FlagAssigned.AppliedFlag.newBuilder() + .setFlag("flags/test-flag-" + i) + .setTargetingKey("user-" + i) + .setAssignmentId("assignment-" + i) + .build()) + .build()); + } + return list; + } + + private GrpcWasmFlagLogger createLoggerWithMockStub( + InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub mockStub) { + // Create logger with synchronous test writer + return new GrpcWasmFlagLogger( + new ApiSecret("test-client-id", "test-client-secret"), mockStub::writeFlagLogs); + } +}