From 96a3427bb9bcb640165306d16af141e9455e2cc4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Sat, 19 Sep 2026 02:56:42 +0000 Subject: [PATCH] Identify Java feature flag events on local and direct EVP routes Add immutable per-request identity headers independently of forwarding capability checks and send-once policy. Emit dd-trace-java and the tracer version for both exposure and flag-evaluation requests. Validation: communication tests 235/235 and feature-flagging-lib tests 279/279 pass; scoped Spotless formatting passes. Local/direct wire assertions preserve credential isolation and one-send behavior. Environment: Datadog workspace --- .../communication/BackendApiFactory.java | 49 +++++++++++-- .../java/datadog/communication/EvpProxy.java | 3 + .../communication/BackendApiFactoryTest.java | 69 +++++++++++++++++-- .../FeatureFlagBackendApiFactory.java | 15 +++- 4 files changed, 126 insertions(+), 10 deletions(-) diff --git a/communication/src/main/java/datadog/communication/BackendApiFactory.java b/communication/src/main/java/datadog/communication/BackendApiFactory.java index b233094f048..31020eca2ea 100644 --- a/communication/src/main/java/datadog/communication/BackendApiFactory.java +++ b/communication/src/main/java/datadog/communication/BackendApiFactory.java @@ -1,6 +1,8 @@ package datadog.communication; import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableMap; import datadog.communication.ddagent.DDAgentFeaturesDiscovery; import datadog.communication.ddagent.SharedCommunicationObjects; @@ -8,9 +10,12 @@ import datadog.trace.api.Config; import datadog.trace.api.intake.Intake; import datadog.trace.util.throwable.FatalAgentMisconfigurationError; +import java.util.HashMap; +import java.util.Map; import javax.annotation.Nullable; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; +import okhttp3.Request; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,23 +27,40 @@ public class BackendApiFactory { private final Config config; private final SharedCommunicationObjects sharedCommunicationObjects; + private final Map requestHeaders; private final boolean sendOnce; public BackendApiFactory(Config config, SharedCommunicationObjects sharedCommunicationObjects) { - this(config, sharedCommunicationObjects, false); + this(config, sharedCommunicationObjects, emptyMap()); + } + + public BackendApiFactory( + Config config, + SharedCommunicationObjects sharedCommunicationObjects, + Map requestHeaders) { + this(config, sharedCommunicationObjects, requestHeaders, false); + } + + public BackendApiFactory( + Config config, SharedCommunicationObjects sharedCommunicationObjects, boolean sendOnce) { + this(config, sharedCommunicationObjects, emptyMap(), sendOnce); } /** - * Creates a backend factory with optional send-once transport semantics. + * Creates a backend factory with per-request headers and optional send-once transport semantics. * *

When {@code sendOnce} is true, both the explicit HTTP retry policy and OkHttp's automatic * connection retry are disabled. This is required for event payloads that do not carry an * idempotency key. */ public BackendApiFactory( - Config config, SharedCommunicationObjects sharedCommunicationObjects, boolean sendOnce) { + Config config, + SharedCommunicationObjects sharedCommunicationObjects, + Map requestHeaders, + boolean sendOnce) { this.config = config; this.sharedCommunicationObjects = sharedCommunicationObjects; + this.requestHeaders = unmodifiableMap(new HashMap<>(requestHeaders)); this.sendOnce = sendOnce; } @@ -181,7 +203,7 @@ private static boolean isAsciiLetterOrDigit(final char character) { * Creates an EVP proxy client after Agent discovery, optionally forcing a fresh discovery and * requiring the Agent to advertise every specified forwarding header. * - *

Callers supply the forwarding capabilities their event protocol requires. + *

Required forwarding capabilities are independent of any headers configured on this factory. * *

The {@code forceDiscovery} form is intended for bounded unavailable-route recovery probes. */ @@ -250,7 +272,24 @@ static HttpUrl appendPath(final HttpUrl baseUrl, final String path) { } OkHttpClient configureHttpClient(final OkHttpClient httpClient) { - return sendOnce ? httpClient.newBuilder().retryOnConnectionFailure(false).build() : httpClient; + if (requestHeaders.isEmpty() && !sendOnce) { + return httpClient; + } + final OkHttpClient.Builder builder = httpClient.newBuilder(); + if (sendOnce) { + builder.retryOnConnectionFailure(false); + } + if (!requestHeaders.isEmpty()) { + builder.addInterceptor( + chain -> { + final Request.Builder requestBuilder = chain.request().newBuilder(); + for (Map.Entry header : requestHeaders.entrySet()) { + requestBuilder.header(header.getKey(), header.getValue()); + } + return chain.proceed(requestBuilder.build()); + }); + } + return builder.build(); } private HttpRetryPolicy.Factory retryPolicyFactory() { diff --git a/communication/src/main/java/datadog/communication/EvpProxy.java b/communication/src/main/java/datadog/communication/EvpProxy.java index 301c48f0ff5..05ce9757339 100644 --- a/communication/src/main/java/datadog/communication/EvpProxy.java +++ b/communication/src/main/java/datadog/communication/EvpProxy.java @@ -11,6 +11,9 @@ public final class EvpProxy { /** Identifies the version of the SDK that produced an EVP request. */ public static final String ORIGIN_VERSION_HEADER = "DD-EVP-ORIGIN-VERSION"; + /** Origin header value identifying this tracing library. */ + public static final String JAVA_TRACING_LIBRARY = "dd-trace-java"; + /** * Default SDK-side target for uncompressed EVP request bodies. Writers may split batches at or * below this size to keep Agent proxy requests comfortably bounded. diff --git a/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java b/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java index f3479237c01..6974d413fa6 100644 --- a/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java +++ b/communication/src/test/java/datadog/communication/BackendApiFactoryTest.java @@ -1,10 +1,14 @@ package datadog.communication; +import static datadog.communication.EvpProxy.JAVA_TRACING_LIBRARY; import static datadog.communication.EvpProxy.ORIGIN_HEADER; import static datadog.communication.EvpProxy.ORIGIN_VERSION_HEADER; import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT; import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V4_EVP_PROXY_ENDPOINT; +import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_AGENTLESS_URL; +import static datadog.trace.api.config.GeneralConfig.API_KEY; import static java.util.Arrays.asList; +import static java.util.Collections.singletonMap; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -22,7 +26,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.HashMap; import java.util.Locale; +import java.util.Map; +import java.util.Properties; import java.util.stream.Stream; import okhttp3.HttpUrl; import okhttp3.MediaType; @@ -327,7 +334,7 @@ void discoveryRejectsMissingOrPartialIdentityCapabilities(final String capabilit } @Test - void featureFlagProxyRequestNeverCarriesDirectCredentials() throws Exception { + void featureFlagProxyRequestCarriesSdkIdentityWithoutDirectCredentials() throws Exception { final MockWebServer agent = new MockWebServer(); agent.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); agent.start(); @@ -335,7 +342,10 @@ void featureFlagProxyRequestNeverCarriesDirectCredentials() throws Exception { final FakeFeaturesDiscovery discovery = new FakeFeaturesDiscovery(V4_EVP_PROXY_ENDPOINT); final BackendApiFactory factory = new BackendApiFactory( - Config.get(), sharedCommunicationObjects(discovery, agent.url("/")), true); + Config.get(), + sharedCommunicationObjects(discovery, agent.url("/")), + sdkHeaders(), + true); final BackendApi api = factory.createEvpProxyApi( Intake.EVENT_PLATFORM, @@ -355,6 +365,8 @@ void featureFlagProxyRequestNeverCarriesDirectCredentials() throws Exception { final RecordedRequest request = agent.takeRequest(); assertEquals("/evp_proxy/v4/api/v2/exposures", request.getPath()); assertEquals("event-platform-intake", request.getHeader("X-Datadog-EVP-Subdomain")); + assertEquals(JAVA_TRACING_LIBRARY, request.getHeader(ORIGIN_HEADER)); + assertEquals("test-version", request.getHeader(ORIGIN_VERSION_HEADER)); assertNull(request.getHeader("DD-API-KEY")); assertEquals(1, agent.getRequestCount()); } finally { @@ -363,7 +375,44 @@ void featureFlagProxyRequestNeverCarriesDirectCredentials() throws Exception { } @Test - void featureFlagDirectRequestCarriesCredentialsExactlyOnce() throws Exception { + void directIntakeSendsConfiguredRequestHeaders() throws Exception { + final MockWebServer intake = new MockWebServer(); + intake.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + intake.start(); + try { + final Properties properties = new Properties(); + properties.setProperty(API_KEY, "api-key"); + properties.setProperty( + CIVISIBILITY_AGENTLESS_URL, intake.url("/").toString().replaceAll("/$", "")); + final Config config = Config.get(properties); + final BackendApiFactory factory = + new BackendApiFactory( + config, + sharedCommunicationObjects(new FakeFeaturesDiscovery(null), null), + singletonMap(ORIGIN_HEADER, JAVA_TRACING_LIBRARY)); + + // followRedirects=false mirrors the feature-flagging caller, so this also covers the + // interaction between the redirect-scoped client and the header interceptor. + final BackendApi api = factory.createDirectIntakeApi(Intake.API, false, false); + + assertNotNull(api); + api.post( + "flagevaluation", + RequestBody.create(JSON, "{}".getBytes(StandardCharsets.UTF_8)), + stream -> null, + null, + false); + + final RecordedRequest request = intake.takeRequest(); + assertEquals(JAVA_TRACING_LIBRARY, request.getHeader(ORIGIN_HEADER)); + assertEquals("api-key", request.getHeader("DD-API-KEY")); + } finally { + intake.shutdown(); + } + } + + @Test + void featureFlagDirectRequestCarriesCredentialsAndIdentityExactlyOnce() throws Exception { final MockWebServer intake = new MockWebServer(); intake.enqueue(new MockResponse().setResponseCode(500).setBody("failed")); intake.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); @@ -371,7 +420,10 @@ void featureFlagDirectRequestCarriesCredentialsExactlyOnce() throws Exception { final OkHttpClient sharedClient = new OkHttpClient.Builder().build(); final BackendApiFactory factory = new BackendApiFactory( - Config.get(), sharedCommunicationObjects(new FakeFeaturesDiscovery(null), null), true); + Config.get(), + sharedCommunicationObjects(new FakeFeaturesDiscovery(null), null), + sdkHeaders(), + true); final OkHttpClient directClient = factory.configureHttpClient(sharedClient); try { final IntakeApi api = @@ -396,6 +448,8 @@ void featureFlagDirectRequestCarriesCredentialsExactlyOnce() throws Exception { final RecordedRequest request = intake.takeRequest(); assertEquals("/api/v2/flagevaluation", request.getPath()); assertEquals("api-key", request.getHeader("DD-API-KEY")); + assertEquals("dd-trace-java", request.getHeader("DD-EVP-ORIGIN")); + assertEquals("test-version", request.getHeader("DD-EVP-ORIGIN-VERSION")); assertNull(request.getHeader("X-Datadog-EVP-Subdomain")); assertEquals(1, intake.getRequestCount()); assertEquals(false, directClient.retryOnConnectionFailure()); @@ -523,6 +577,13 @@ private static SharedCommunicationObjects sharedCommunicationObjects( return sco; } + private static Map sdkHeaders() { + final Map headers = new HashMap<>(2); + headers.put("DD-EVP-ORIGIN", "dd-trace-java"); + headers.put("DD-EVP-ORIGIN-VERSION", "test-version"); + return headers; + } + private static final class TestSharedCommunicationObjects extends SharedCommunicationObjects { private final DDAgentFeaturesDiscovery discovery; 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 index f5b75e2f815..0570fe331be 100644 --- 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 @@ -1,17 +1,22 @@ package com.datadog.featureflag; +import static datadog.communication.EvpProxy.JAVA_TRACING_LIBRARY; import static datadog.communication.EvpProxy.ORIGIN_HEADER; import static datadog.communication.EvpProxy.ORIGIN_VERSION_HEADER; import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V2_EVP_PROXY_ENDPOINT; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableMap; import datadog.communication.BackendApi; import datadog.communication.BackendApiFactory; import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.communication.ddagent.TracerVersion; import datadog.communication.http.HttpRetryPolicy; import datadog.trace.api.Config; import datadog.trace.api.intake.Intake; +import java.util.HashMap; +import java.util.Map; import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,6 +25,7 @@ final class FeatureFlagBackendApiFactory { private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagBackendApiFactory.class); + private static final Map REQUEST_HEADERS = requestHeaders(); private final Config config; private final BackendApiFactory backendApiFactory; @@ -40,7 +46,7 @@ final class FeatureFlagBackendApiFactory { final FeatureFlagRouteSelector routeSelector) { this( config, - new BackendApiFactory(config, sharedCommunicationObjects, true), + new BackendApiFactory(config, sharedCommunicationObjects, REQUEST_HEADERS, true), eventType, routeSelector); } @@ -103,6 +109,13 @@ private BackendApi createProxyApi(final boolean forceDiscovery) { asList(ORIGIN_HEADER, ORIGIN_VERSION_HEADER)); } + private static Map requestHeaders() { + final Map headers = new HashMap<>(2); + headers.put(ORIGIN_HEADER, JAVA_TRACING_LIBRARY); + headers.put(ORIGIN_VERSION_HEADER, TracerVersion.TRACER_VERSION); + return unmodifiableMap(headers); + } + private boolean hasDirectCredentials() { final String apiKey = config.getApiKey(); return apiKey != null && !apiKey.isEmpty();