diff --git a/communication/src/main/java/datadog/communication/IntakeApi.java b/communication/src/main/java/datadog/communication/IntakeApi.java index 3f1fe9df67f..1a6f3f91bc7 100644 --- a/communication/src/main/java/datadog/communication/IntakeApi.java +++ b/communication/src/main/java/datadog/communication/IntakeApi.java @@ -25,6 +25,7 @@ public class IntakeApi implements BackendApi { private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding"; private static final String CONTENT_ENCODING_HEADER = "Content-Encoding"; private static final String GZIP_ENCODING = "gzip"; + private static final String IDENTITY_ENCODING = "identity"; private final String apiKey; private final String traceId; @@ -73,9 +74,10 @@ public T post( requestBuilder.addHeader(CONTENT_ENCODING_HEADER, GZIP_ENCODING); } - if (responseCompression) { - requestBuilder.addHeader(ACCEPT_ENCODING_HEADER, GZIP_ENCODING); - } + // OkHttp adds Accept-Encoding: gzip when this header is absent. Always set the header so a + // caller can disable response compression on the wire. + requestBuilder.addHeader( + ACCEPT_ENCODING_HEADER, responseCompression ? GZIP_ENCODING : IDENTITY_ENCODING); Request request = requestBuilder.build(); try (okhttp3.Response response = diff --git a/communication/src/test/java/datadog/communication/IntakeApiTest.java b/communication/src/test/java/datadog/communication/IntakeApiTest.java new file mode 100644 index 00000000000..326cf21ca84 --- /dev/null +++ b/communication/src/test/java/datadog/communication/IntakeApiTest.java @@ -0,0 +1,65 @@ +package datadog.communication; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.communication.http.HttpRetryPolicy; +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class IntakeApiTest { + + private static final MediaType JSON = MediaType.parse("application/json"); + + private MockWebServer server; + private OkHttpClient client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = new OkHttpClient.Builder().build(); + } + + @AfterEach + void tearDown() throws IOException { + client.dispatcher().executorService().shutdownNow(); + client.connectionPool().evictAll(); + server.shutdown(); + } + + @Test + void requestsGzipResponseCompressionWhenEnabled() throws Exception { + assertEquals("gzip", postAndReadAcceptEncoding(true)); + } + + @Test + void requestsIdentityResponseEncodingWhenCompressionIsDisabled() throws Exception { + assertEquals("identity", postAndReadAcceptEncoding(false)); + } + + private String postAndReadAcceptEncoding(final boolean responseCompression) throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + final IntakeApi api = + new IntakeApi( + server.url("/api/v2/"), + "api-key", + "123", + HttpRetryPolicy.Factory.NEVER_RETRY, + client, + responseCompression); + + api.post("flagevaluation", RequestBody.create(JSON, "{}"), responseBody -> null, null, false); + + final RecordedRequest request = server.takeRequest(); + assertEquals("/api/v2/flagevaluation", request.getPath()); + return request.getHeader("Accept-Encoding"); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java similarity index 74% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java rename to products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java index 28f046832ab..769ebfd1dd1 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java @@ -13,21 +13,26 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** Sends exposures through a local EVP proxy, with a safe direct intake fallback. */ -final class AgentlessExposureBackendApi implements BackendApi { +/** Sends Feature Flag events through a local EVP proxy, with a safe direct intake fallback. */ +final class AgentlessFeatureFlagBackendApi implements BackendApi { - private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessExposureBackendApi.class); + private static final Logger LOGGER = + LoggerFactory.getLogger(AgentlessFeatureFlagBackendApi.class); - private final BackendApi localApi; + private final BackendApi proxyApi; private final Supplier directApiSupplier; + private final String eventType; private volatile BackendApi activeApi; private volatile boolean directApiCreationAttempted; - AgentlessExposureBackendApi( - final BackendApi localApi, final Supplier directApiSupplier) { - this.localApi = localApi; + AgentlessFeatureFlagBackendApi( + final BackendApi proxyApi, + final Supplier directApiSupplier, + final String eventType) { + this.proxyApi = proxyApi; this.directApiSupplier = directApiSupplier; - this.activeApi = localApi; + this.eventType = eventType; + this.activeApi = proxyApi; } @Override @@ -43,7 +48,7 @@ public T post( return selectedApi.post( uri, requestBody, responseParser, requestListener, requestCompression); } catch (final IOException exception) { - if (selectedApi != localApi || !isDefinitiveRejection(exception)) { + if (selectedApi != proxyApi || !isDefinitiveRejection(exception)) { throw exception; } @@ -58,13 +63,13 @@ public T post( @Nullable private BackendApi getOrCreateDirectApi() { final BackendApi selectedApi = activeApi; - if (selectedApi != localApi) { + if (selectedApi != proxyApi) { return selectedApi; } synchronized (this) { final BackendApi currentApi = activeApi; - if (currentApi != localApi) { + if (currentApi != proxyApi) { return currentApi; } if (directApiCreationAttempted) { @@ -74,7 +79,8 @@ private BackendApi getOrCreateDirectApi() { final BackendApi directApi = directApiSupplier.get(); if (directApi != null) { LOGGER.debug( - "Switching Feature Flagging exposure delivery from the local EVP proxy to direct intake"); + "Switching Feature Flagging {} delivery from the local EVP proxy to direct intake", + eventType); activeApi = directApi; } directApiCreationAttempted = true; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java deleted file mode 100644 index 8381cbec078..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Selects the transport for Feature Flagging exposure events. */ -final class ExposureBackendApiFactory { - - private static final Logger LOGGER = LoggerFactory.getLogger(ExposureBackendApiFactory.class); - - private final Config config; - private final BackendApiFactory backendApiFactory; - - ExposureBackendApiFactory( - final Config config, final SharedCommunicationObjects sharedCommunicationObjects) { - this(config, new BackendApiFactory(config, sharedCommunicationObjects)); - } - - ExposureBackendApiFactory(final Config config, final BackendApiFactory backendApiFactory) { - this.config = config; - this.backendApiFactory = backendApiFactory; - } - - @Nullable - BackendApi create() { - final BackendApi localApi = backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM); - if (!CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - if (localApi == null) { - LOGGER.warn( - "Feature Flagging exposure delivery is disabled because the local Agent does not support the EVP proxy"); - } - return localApi; - } - - if (localApi != null) { - if (hasDirectCredentials()) { - return new AgentlessExposureBackendApi(localApi, this::createDirectApi); - } - return localApi; - } - - final BackendApi directApi = createDirectApi(); - if (directApi != null) { - return directApi; - } - - LOGGER.warn( - "Feature Flagging exposure delivery is disabled because no compatible local EVP proxy or direct intake credentials are available"); - return null; - } - - private boolean hasDirectCredentials() { - final String apiKey = config.getApiKey(); - return apiKey != null && !apiKey.isEmpty(); - } - - @Nullable - private BackendApi createDirectApi() { - if (!hasDirectCredentials()) { - return null; - } - try { - return backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM); - } catch (final IllegalArgumentException exception) { - LOGGER.debug("Cannot configure direct Feature Flagging exposure delivery", exception); - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java index 928c2da6681..017616ed070 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -41,14 +41,19 @@ public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config con final TimeUnit timeUnit, final SharedCommunicationObjects sco, final Config config) { - this(capacity, flushInterval, timeUnit, new ExposureBackendApiFactory(config, sco), config); + this( + capacity, + flushInterval, + timeUnit, + new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.EXPOSURE), + config); } ExposureWriterImpl( final int capacity, final long flushInterval, final TimeUnit timeUnit, - final ExposureBackendApiFactory backendApiFactory, + final FeatureFlagBackendApiFactory backendApiFactory, final Config config) { this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); final ExposureSerializingHandler serializer = @@ -104,7 +109,7 @@ private static class ExposureSerializingHandler implements Runnable { private final Runnable errorCallback; ExposureSerializingHandler( - final ExposureBackendApiFactory backendApiFactory, + final FeatureFlagBackendApiFactory backendApiFactory, final MessagePassingBlockingQueue queue, final long flushInterval, final TimeUnit timeUnit, diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java new file mode 100644 index 00000000000..1727d156915 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java @@ -0,0 +1,91 @@ +package com.datadog.featureflag; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; + +import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.trace.api.Config; +import datadog.trace.api.intake.Intake; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Selects the transport for Feature Flagging events. */ +final class FeatureFlagBackendApiFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagBackendApiFactory.class); + + private final Config config; + private final BackendApiFactory backendApiFactory; + private final FeatureFlagEventType eventType; + + FeatureFlagBackendApiFactory( + final Config config, + final SharedCommunicationObjects sharedCommunicationObjects, + final FeatureFlagEventType eventType) { + this(config, new BackendApiFactory(config, sharedCommunicationObjects), eventType); + } + + FeatureFlagBackendApiFactory( + final Config config, + final BackendApiFactory backendApiFactory, + final FeatureFlagEventType eventType) { + this.config = config; + this.backendApiFactory = backendApiFactory; + this.eventType = eventType; + } + + @Nullable + BackendApi create() { + final BackendApi proxyApi = + backendApiFactory.createEvpProxyApi( + Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); + if (!CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { + if (proxyApi == null) { + LOGGER.warn( + "Feature Flagging {} delivery is disabled because the local Agent does not support the EVP proxy", + eventType.logName()); + } + return proxyApi; + } + + if (proxyApi != null) { + if (hasDirectCredentials()) { + return new AgentlessFeatureFlagBackendApi( + proxyApi, this::createDirectApi, eventType.logName()); + } + return proxyApi; + } + + final BackendApi directApi = createDirectApi(); + if (directApi != null) { + return directApi; + } + + LOGGER.warn( + "Feature Flagging {} delivery is disabled because no compatible local EVP proxy or direct intake credentials are available", + eventType.logName()); + return null; + } + + private boolean hasDirectCredentials() { + final String apiKey = config.getApiKey(); + return apiKey != null && !apiKey.isEmpty(); + } + + @Nullable + private BackendApi createDirectApi() { + if (!hasDirectCredentials()) { + return null; + } + try { + return backendApiFactory.createDirectIntakeApi( + Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); + } catch (final IllegalArgumentException exception) { + LOGGER.debug( + "Cannot configure direct Feature Flagging {} delivery", eventType.logName(), exception); + return null; + } + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java new file mode 100644 index 00000000000..dee8b424fa3 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java @@ -0,0 +1,26 @@ +package com.datadog.featureflag; + +/** Defines event-specific transport behavior for Feature Flag delivery. */ +enum FeatureFlagEventType { + // Keep the established exposure transport behavior for compatibility. + EXPOSURE("exposure", true), + + // Flag evaluation writers ignore successful response bodies, so gzip negotiation adds no value. + FLAG_EVALUATION("flag evaluation", false); + + private final String logName; + private final boolean responseCompressionEnabled; + + FeatureFlagEventType(final String logName, final boolean responseCompressionEnabled) { + this.logName = logName; + this.responseCompressionEnabled = responseCompressionEnabled; + } + + String logName() { + return logName; + } + + boolean responseCompressionEnabled() { + return responseCompressionEnabled; + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java index e15666aa10a..1bc203022ac 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java @@ -6,6 +6,7 @@ import datadog.common.queue.MessagePassingBlockingQueue; import datadog.common.queue.Queues; +import datadog.communication.BackendApi; import datadog.communication.BackendApiFactory; import datadog.communication.EvpProxy; import datadog.communication.ddagent.SharedCommunicationObjects; @@ -13,6 +14,7 @@ import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; +import datadog.trace.api.intake.Intake; import datadog.trace.api.telemetry.CoreMetricCollector; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; @@ -23,6 +25,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -108,7 +111,12 @@ public FlagEvaluationWriterImpl(final SharedCommunicationObjects sco, final Conf final TimeUnit timeUnit, final SharedCommunicationObjects sco, final Config config) { - this(capacity, flushInterval, timeUnit, new BackendApiFactory(config, sco), config); + this( + capacity, + flushInterval, + timeUnit, + new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.FLAG_EVALUATION), + config); } /** Package-private constructor allowing a BackendApiFactory to be injected for tests. */ @@ -118,10 +126,36 @@ public FlagEvaluationWriterImpl(final SharedCommunicationObjects sco, final Conf final TimeUnit timeUnit, final BackendApiFactory backendApiFactory, final Config config) { + this( + capacity, + flushInterval, + timeUnit, + () -> + backendApiFactory.createBackendApi( + Intake.EVENT_PLATFORM, + FeatureFlagEventType.FLAG_EVALUATION.responseCompressionEnabled()), + config); + } + + FlagEvaluationWriterImpl( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final FeatureFlagBackendApiFactory backendApiFactory, + final Config config) { + this(capacity, flushInterval, timeUnit, backendApiFactory::create, config); + } + + private FlagEvaluationWriterImpl( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final Supplier backendApiSupplier, + final Config config) { this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); this.serializer = new FlagEvaluationSerializingHandler( - backendApiFactory, + backendApiSupplier, queue, flushInterval, timeUnit, @@ -318,7 +352,7 @@ static class FlagEvaluationSerializingHandler implements Runnable { final ConcurrentHashMap contextTruncatedCounts, final Runnable errorCallback) { this( - backendApiFactory, + () -> backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM, false), queue, flushInterval, timeUnit, @@ -339,10 +373,53 @@ static class FlagEvaluationSerializingHandler implements Runnable { final ConcurrentHashMap contextTruncatedCounts, final Runnable errorCallback, final int payloadSizeLimitBytes) { + this( + () -> backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM, false), + queue, + flushInterval, + timeUnit, + context, + droppedQueueOverflow, + contextTruncatedCounts, + errorCallback, + payloadSizeLimitBytes); + } + + FlagEvaluationSerializingHandler( + final Supplier backendApiSupplier, + final MessagePassingBlockingQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context, + final AtomicLong droppedQueueOverflow, + final ConcurrentHashMap contextTruncatedCounts, + final Runnable errorCallback) { + this( + backendApiSupplier, + queue, + flushInterval, + timeUnit, + context, + droppedQueueOverflow, + contextTruncatedCounts, + errorCallback, + FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + } + + FlagEvaluationSerializingHandler( + final Supplier backendApiSupplier, + final MessagePassingBlockingQueue queue, + final long flushInterval, + final TimeUnit timeUnit, + final Map context, + final AtomicLong droppedQueueOverflow, + final ConcurrentHashMap contextTruncatedCounts, + final Runnable errorCallback, + final int payloadSizeLimitBytes) { this.queue = queue; this.evpPublisher = new FeatureFlagEvpPublisher<>( - backendApiFactory, FlagEvaluationPayloads.FlagEvaluationsRequest.class, false); + backendApiSupplier, FlagEvaluationPayloads.FlagEvaluationsRequest.class); this.context = context; this.droppedQueueOverflow = droppedQueueOverflow; this.contextTruncatedCounts = contextTruncatedCounts; diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java similarity index 78% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java rename to products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java index de34668f0f5..117c72ba2a1 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java @@ -16,14 +16,17 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; import javax.annotation.Nullable; import okhttp3.MediaType; import okhttp3.RequestBody; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -class AgentlessExposureBackendApiTest { +class AgentlessFeatureFlagBackendApiTest { @ParameterizedTest @ValueSource(ints = {403, 404, 405}) @@ -32,19 +35,20 @@ void replaysRejectedBatchDirectlyAndKeepsDirectRoute(final int statusCode) throw new RecordingBackendApi(new HttpResponseException(statusCode, "rejected")); final RecordingBackendApi direct = new RecordingBackendApi(); final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessExposureBackendApi api = - new AgentlessExposureBackendApi( + final AgentlessFeatureFlagBackendApi api = + new AgentlessFeatureFlagBackendApi( local, () -> { directApiCreations.incrementAndGet(); return direct; - }); + }, + "flag evaluation"); final RequestBody firstBody = requestBody("first"); final RequestBody secondBody = requestBody("second"); assertEquals(0, directApiCreations.get()); - api.post("exposures", firstBody, stream -> null, null, false); - api.post("exposures", secondBody, stream -> null, null, false); + api.post("flagevaluation", firstBody, stream -> null, null, false); + api.post("flagevaluation", secondBody, stream -> null, null, false); assertEquals(1, directApiCreations.get()); assertEquals(1, local.calls); @@ -54,14 +58,17 @@ void replaysRejectedBatchDirectlyAndKeepsDirectRoute(final int statusCode) throw assertSame(secondBody, direct.requestBodies.get(1)); } - @Test - void fallsBackAfterConnectionRefusal() throws Exception { + @ParameterizedTest + @MethodSource("featureFlagRoutes") + void fallsBackAfterConnectionRefusal(final String route, final String eventType) + throws Exception { final RecordingBackendApi local = new RecordingBackendApi(new ConnectException("connection refused")); final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessExposureBackendApi api = new AgentlessExposureBackendApi(local, () -> direct); + final AgentlessFeatureFlagBackendApi api = + new AgentlessFeatureFlagBackendApi(local, () -> direct, eventType); - api.post("exposures", requestBody("exposure"), stream -> null, null, false); + api.post(route, requestBody(eventType), stream -> null, null, false); assertEquals(1, local.calls); assertEquals(1, direct.calls); @@ -72,7 +79,8 @@ void doesNotReturnToLocalRouteAfterSwitchingToDirectIntake() throws Exception { final RecordingBackendApi local = new RecordingBackendApi(new ConnectException("connection refused")); final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessExposureBackendApi api = new AgentlessExposureBackendApi(local, () -> direct); + final AgentlessFeatureFlagBackendApi api = + new AgentlessFeatureFlagBackendApi(local, () -> direct, "exposure"); api.post("exposures", requestBody("first"), stream -> null, null, false); direct.failure = new IOException("direct intake failed"); @@ -105,13 +113,14 @@ void doesNotRetryDirectApiCreationWhenFallbackIsUnavailable() { final RecordingBackendApi local = new RecordingBackendApi(new HttpResponseException(404, "rejected")); final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessExposureBackendApi api = - new AgentlessExposureBackendApi( + final AgentlessFeatureFlagBackendApi api = + new AgentlessFeatureFlagBackendApi( local, () -> { directApiCreations.incrementAndGet(); return null; - }); + }, + "exposure"); assertThrows( HttpResponseException.class, @@ -128,17 +137,18 @@ private static void assertNoDirectReplay(final IOException failure) { final RecordingBackendApi local = new RecordingBackendApi(failure); final RecordingBackendApi direct = new RecordingBackendApi(); final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessExposureBackendApi api = - new AgentlessExposureBackendApi( + final AgentlessFeatureFlagBackendApi api = + new AgentlessFeatureFlagBackendApi( local, () -> { directApiCreations.incrementAndGet(); return direct; - }); + }, + "flag evaluation"); assertThrows( IOException.class, - () -> api.post("exposures", requestBody("exposure"), stream -> null, null, false)); + () -> api.post("flagevaluation", requestBody("evaluation"), stream -> null, null, false)); assertEquals(1, local.calls); assertEquals(0, direct.calls); @@ -149,6 +159,11 @@ private static RequestBody requestBody(final String value) { return RequestBody.create(MediaType.parse("application/json"), value); } + private static Stream featureFlagRoutes() { + return Stream.of( + Arguments.of("exposures", "exposure"), Arguments.of("flagevaluation", "flag evaluation")); + } + private static final class RecordingBackendApi implements BackendApi { private IOException failure; private final List requestBodies = new ArrayList<>(); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java index cfd65cceb2c..78e4a72ebba 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java @@ -155,10 +155,11 @@ void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { HttpRetryPolicy.Factory.NEVER_RETRY, new OkHttpClient.Builder().build(), false); - when(backendApiFactory.createDirectIntakeApi(datadog.trace.api.intake.Intake.EVENT_PLATFORM)) + when(backendApiFactory.createDirectIntakeApi( + datadog.trace.api.intake.Intake.EVENT_PLATFORM, true)) .thenReturn(directApi); - ExposureBackendApiFactory exposureBackendApiFactory = - new ExposureBackendApiFactory(config, backendApiFactory); + FeatureFlagBackendApiFactory exposureBackendApiFactory = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); List exposures = buildExposures(5); try (ExposureWriterImpl writer = diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java similarity index 67% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java rename to products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java index 2f966924f44..ddd873a1e9d 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java @@ -1,5 +1,7 @@ package com.datadog.featureflag; +import static com.datadog.featureflag.FeatureFlagEventType.EXPOSURE; +import static com.datadog.featureflag.FeatureFlagEventType.FLAG_EVALUATION; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -16,19 +18,20 @@ import datadog.trace.api.intake.Intake; import org.junit.jupiter.api.Test; -class ExposureBackendApiFactoryTest { +class FeatureFlagBackendApiFactoryTest { @Test void remoteConfigUsesOnlyLocalEvpProxy() { final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi localApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); + final BackendApi proxyApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - assertSame(localApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); + assertSame(proxyApi, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false); } @Test @@ -36,25 +39,28 @@ void remoteConfigDisablesDeliveryWhenLocalEvpProxyIsUnavailable() { final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); assertNull(selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); + verify(backendApiFactory).createEvpProxyApi(Intake.EVENT_PLATFORM, true); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true); } @Test void agentlessPrefersLocalEvpProxyWithDirectFallback() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)) + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)) .thenReturn(mock(BackendApi.class)); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)) + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) .thenReturn(mock(BackendApi.class)); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - assertInstanceOf(AgentlessExposureBackendApi.class, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); + assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false); } @Test @@ -62,9 +68,11 @@ void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)).thenReturn(directApi); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) + .thenReturn(directApi); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); assertSame(directApi, selected); } @@ -73,13 +81,14 @@ void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { void agentlessUsesLocalEvpProxyWhenApiKeyIsUnavailable() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi localApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); + final BackendApi proxyApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - assertSame(localApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); + assertSame(proxyApi, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false); } @Test @@ -87,7 +96,8 @@ void agentlessDisablesDeliveryWhenNoRouteIsAvailable() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); assertNull(selected); } @@ -97,35 +107,38 @@ void agentlessDisablesDeliveryWhenApiKeyIsEmpty() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, ""); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); assertNull(selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true); } @Test void agentlessDoesNotValidateDirectUrlWhileLocalRouteIsAvailable() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi localApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)) + final BackendApi proxyApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) .thenThrow(new IllegalArgumentException("invalid URL")); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - assertInstanceOf(AgentlessExposureBackendApi.class, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); + assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false); } @Test void agentlessDisablesDeliveryWhenDirectUrlIsInvalidAndLocalRouteIsUnavailable() { final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)) + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) .thenThrow(new IllegalArgumentException("invalid URL")); - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + final BackendApi selected = + new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); assertNull(selected); } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java index b354fdbb6c4..5d366c8b689 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java @@ -11,6 +11,7 @@ import static com.datadog.featureflag.FlagEvaluationTestSupport.metricSum; import static com.datadog.featureflag.FlagEvaluationTestSupport.repeat; import static com.datadog.featureflag.FlagEvaluationTestSupport.simpleEvent; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -31,13 +32,18 @@ import datadog.common.queue.Queues; import datadog.communication.BackendApi; import datadog.communication.BackendApiFactory; +import datadog.communication.IntakeApi; import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.communication.http.HttpRetryPolicy; +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import datadog.trace.api.intake.Intake; import datadog.trace.api.telemetry.CoreMetricCollector; import datadog.trace.api.telemetry.MetricCollector; +import datadog.trace.test.util.PollingConditions; import java.io.IOException; import java.lang.reflect.Field; import java.util.Collection; @@ -46,6 +52,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; import okhttp3.RequestBody; import okio.Buffer; import org.junit.jupiter.api.AfterEach; @@ -54,6 +62,10 @@ class FlagEvaluationWriterImplTest { + private static final String DIRECT_FLAG_EVALUATION_ENDPOINT = "/api/v2/flagevaluation"; + private static final String API_KEY = "test-api-key"; + private static final double TIMEOUT_SECONDS = 5; + @BeforeEach void clearCoreMetricsBefore() { clearCoreMetrics(); @@ -650,6 +662,54 @@ void scoConstructorCreatesUsableWriter() { writer.close(); } + @Test + void agentlessWritesFlagEvaluationsDirectlyWhenLocalProxyIsUnavailable() throws Exception { + try (JavaTestHttpServer server = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> + h.prefix( + DIRECT_FLAG_EVALUATION_ENDPOINT, + api -> api.getResponse().status(200).send("OK"))))) { + final Config config = cfg(); + when(config.getFeatureFlaggingConfigurationSource()) + .thenReturn(CONFIGURATION_SOURCE_AGENTLESS); + when(config.getApiKey()).thenReturn(API_KEY); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final IntakeApi directApi = + new IntakeApi( + HttpUrl.get(server.getAddress()).resolve("/api/v2/"), + API_KEY, + "123", + HttpRetryPolicy.Factory.NEVER_RETRY, + new OkHttpClient.Builder().build(), + false); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false)) + .thenReturn(directApi); + final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = + new FeatureFlagBackendApiFactory( + config, backendApiFactory, FeatureFlagEventType.FLAG_EVALUATION); + final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); + + try (FlagEvaluationWriterImpl writer = + new FlagEvaluationWriterImpl( + 16, 1, TimeUnit.MILLISECONDS, featureFlagBackendApiFactory, config)) { + writer.startForTest(); + writer.enqueue(simpleEvent("direct-flag", "on")); + + poll.eventually( + () -> { + assertNotNull(server.getLastRequest()); + assertEquals(DIRECT_FLAG_EVALUATION_ENDPOINT, server.getLastRequest().getPath()); + assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); + assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); + assertTrue(server.getLastRequest().getBody().length > 0); + }); + } + } + } + @Test void countContextTruncatedAccumulatesPerReason() { final BackendApi mockEvp = mock(BackendApi.class);