diff --git a/dd-java-agent/agent-bootstrap/build.gradle b/dd-java-agent/agent-bootstrap/build.gradle index fc5866caebe..c554039afa5 100644 --- a/dd-java-agent/agent-bootstrap/build.gradle +++ b/dd-java-agent/agent-bootstrap/build.gradle @@ -23,6 +23,7 @@ dependencies { api project(':dd-java-agent:agent-debugger:debugger-bootstrap') api project(':components:environment') api project(':components:json') + api project(':products:feature-flagging:feature-flagging-config') api project(':products:metrics:metrics-agent') api libs.instrument.java api libs.slf4j diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java index 876a07f1fd1..249453c58fb 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java @@ -34,7 +34,6 @@ import datadog.trace.api.config.CrashTrackingConfig; import datadog.trace.api.config.CwsConfig; import datadog.trace.api.config.DebuggerConfig; -import datadog.trace.api.config.FeatureFlaggingConfig; import datadog.trace.api.config.GeneralConfig; import datadog.trace.api.config.IastConfig; import datadog.trace.api.config.JmxFetchConfig; @@ -44,6 +43,7 @@ import datadog.trace.api.config.TraceInstrumentationConfig; import datadog.trace.api.config.TracerConfig; import datadog.trace.api.config.UsmConfig; +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.api.gateway.SubscriptionService; import datadog.trace.api.git.EmbeddedGitInfoBuilder; diff --git a/dd-trace-api/build.gradle.kts b/dd-trace-api/build.gradle.kts index 9716588d34e..46e975af41f 100644 --- a/dd-trace-api/build.gradle.kts +++ b/dd-trace-api/build.gradle.kts @@ -38,7 +38,6 @@ extra["excludedClassesCoverage"] = listOf( "datadog.trace.api.civisibility.noop.NoOpDDTestSession", "datadog.trace.api.civisibility.noop.NoOpDDTestSuite", "datadog.trace.api.config.AIGuardConfig", - "datadog.trace.api.config.FeatureFlaggingConfig", "datadog.trace.api.config.ProfilingConfig", "datadog.trace.api.interceptor.MutableSpan", "datadog.trace.api.profiling.Profiling", diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java deleted file mode 100644 index 28151f88864..00000000000 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java +++ /dev/null @@ -1,6 +0,0 @@ -package datadog.trace.api.config; - -public class FeatureFlaggingConfig { - - public static final String FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; -} diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 733f3736cbb..335c3db4b55 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -1505,6 +1505,14 @@ "aliases": [] } ], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "false", + "aliases": [] + } + ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ { "version": "B", diff --git a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java index 02689767bad..9761caf3895 100644 --- a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java +++ b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java @@ -11,6 +11,7 @@ public class FeatureFlaggingSystem { private static volatile RemoteConfigService CONFIG_SERVICE; private static volatile ExposureWriter EXPOSURE_WRITER; + private static volatile SpanEnrichmentWriter SPAN_ENRICHMENT_WRITER; private FeatureFlaggingSystem() {} @@ -27,10 +28,22 @@ public static void start(final SharedCommunicationObjects sco) { EXPOSURE_WRITER = new ExposureWriterImpl(sco, config); EXPOSURE_WRITER.init(); + // APM span enrichment: agent-side listener for flag-evaluation seam events. Uses the process- + // wide singleton so a subsystem restart reuses the one already-registered trace interceptor + // (which the tracer cannot remove) instead of registering a second, rejected one. Cheap: it + // only accumulates once the provider's gate-on capture hook dispatches events, and registers + // its interceptor lazily on the first such event. + SPAN_ENRICHMENT_WRITER = SpanEnrichmentWriter.getInstance(); + SPAN_ENRICHMENT_WRITER.init(); + LOGGER.debug("Feature Flagging system started"); } public static void stop() { + if (SPAN_ENRICHMENT_WRITER != null) { + SPAN_ENRICHMENT_WRITER.close(); + SPAN_ENRICHMENT_WRITER = null; + } if (EXPOSURE_WRITER != null) { EXPOSURE_WRITER.close(); EXPOSURE_WRITER = null; diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index def6a16da8c..ba8b43ca32c 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -44,9 +44,12 @@ dependencies { api("dev.openfeature:sdk:1.20.1") compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap")) + compileOnly(project(":products:feature-flagging:feature-flagging-config")) + compileOnly(project(":utils:config-utils")) compileOnly("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) + testImplementation(project(":utils:config-utils")) testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index 91c0aafdc7a..654fb6e8f6c 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -46,6 +46,16 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { private static final Set> SUPPORTED_RESOLUTION_TYPES = new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); + // Evaluation-metadata keys consumed by the span-enrichment capture hook (see + // SpanEnrichmentHook). Emitted only when the span-enrichment gate is on. + static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; + static final String METADATA_DO_LOG = "__dd_do_log"; + + // Read once: when off, the __dd_* span-enrichment metadata is not attached to evaluations, so an + // enabled provider pays nothing extra unless span enrichment is also enabled. The gate does not + // change at runtime, and this class is loaded lazily (well after startup) so config is ready. + private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); + private final Runnable configCallback; private final AtomicReference configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); @@ -392,6 +402,17 @@ private static ProviderEvaluation resolveVariant( .addString("flagKey", flag.key) .addString("variationType", flag.variationType.name()) .addString("allocationKey", allocation.key); + // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — + // only when span enrichment is on, so a provider without enrichment pays nothing extra. + // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always + // present (when enrichment is on) so the span-enrichment hook can decide whether to record the + // subject. + if (SPAN_ENRICHMENT_ENABLED) { + if (split.serialId != null) { + metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); + } + metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); + } final ProviderEvaluation result = ProviderEvaluation.builder() .value(mappedValue) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index c5e8898fa4a..38fa735d4e9 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -16,6 +16,7 @@ import dev.openfeature.sdk.exceptions.OpenFeatureError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import java.lang.reflect.Constructor; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; @@ -28,6 +29,7 @@ public class Provider extends EventProvider implements Metadata { private static final Logger log = LoggerFactory.getLogger(Provider.class); static final String METADATA = "datadog-openfeature-provider"; private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator"; + private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); private volatile Evaluator evaluator; private final Options options; @@ -35,6 +37,11 @@ public class Provider extends EventProvider implements Metadata { new AtomicReference<>(InitializationState.NOT_STARTED); private final FlagEvalMetrics flagEvalMetrics; private final FlagEvalHook flagEvalHook; + // Span enrichment: null unless the gate is on, so the feature has no idle overhead when off. + private final SpanEnrichmentHook spanEnrichmentHook; + // Precomputed hook list returned by getProviderHooks() on every evaluation. Immutable and built + // once so gate-off evaluation allocates nothing on this hot path. + private final List providerHooks; public Provider() { this(DEFAULT_OPTIONS, null); @@ -45,6 +52,17 @@ public Provider(final Options options) { } Provider(final Options options, final Evaluator evaluator) { + this(options, evaluator, null); + } + + /** + * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test + * seam); when null, the gate is read via {@link SpanEnrichmentGate}. + */ + Provider( + final Options options, + final Evaluator evaluator, + final Boolean spanEnrichmentEnabledOverride) { this.options = options; this.evaluator = evaluator; FlagEvalMetrics metrics = null; @@ -58,6 +76,34 @@ public Provider(final Options options) { } this.flagEvalMetrics = metrics; this.flagEvalHook = hook; + + // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle + // per-evaluation overhead. + final boolean spanEnrichmentEnabled = + spanEnrichmentEnabledOverride != null + ? spanEnrichmentEnabledOverride + : SpanEnrichmentGate.isEnabled(); + this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; + + // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) + // allocates nothing, including when the gate is off. + final List hooks = new ArrayList<>(2); + if (flagEvalHook != null) { + hooks.add(flagEvalHook); + } + if (spanEnrichmentHook != null) { + hooks.add(spanEnrichmentHook); + } + this.providerHooks = + hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); + + // Announce the span-enrichment state at startup (matches the reference implementation). + // "enabled" only when the gate is on (the capture hook was constructed), otherwise "disabled". + if (spanEnrichmentHook != null) { + log.info("{} span enrichment enabled", METADATA); + } else { + log.info("{} span enrichment disabled", METADATA); + } } @Override @@ -167,10 +213,7 @@ private Evaluator buildEvaluator() throws Exception { @Override public List getProviderHooks() { - if (flagEvalHook == null) { - return Collections.emptyList(); - } - return Collections.singletonList(flagEvalHook); + return providerHooks; } @Override @@ -178,11 +221,19 @@ public void shutdown() { if (flagEvalMetrics != null) { flagEvalMetrics.shutdown(); } + // Span enrichment needs no provider-close cleanup here: the capture hook holds no tracer state. + // The agent-side write tier owns the interceptor and per-trace state and is torn down with the + // feature-flagging subsystem, not per provider. if (evaluator != null) { evaluator.shutdown(); } } + // Visible for tests: expose whether span enrichment is wired (gate-on) without leaking the impl. + SpanEnrichmentHook spanEnrichmentHook() { + return spanEnrichmentHook; + } + @Override public Metadata getMetadata() { return this; @@ -223,7 +274,7 @@ public ProviderEvaluation getObjectEvaluation( return evaluator.evaluate(Value.class, key, defaultValue, ctx); } - @SuppressForbidden // Class#forName(String) used to lazy load internal-api dependencies + @SuppressForbidden // Class#forName(String) used to lazy-load the evaluator implementation protected Class loadEvaluatorClass() throws ClassNotFoundException { return Class.forName(EVALUATOR_IMPL); } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java new file mode 100644 index 00000000000..64739f0ad39 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java @@ -0,0 +1,25 @@ +package datadog.trace.api.openfeature; + +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; +import datadog.trace.bootstrap.config.provider.ConfigProvider; + +/** + * Single source for reading the experimental span-enrichment gate, with full {@link ConfigProvider} + * precedence (system property > stable config > env var {@code + * DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED}). OFF by default; distinct from the + * provider-enabled gate. Shared so {@link Provider} (per construction) and {@link DDEvaluator} + * (once at class load) read it the same way. + */ +final class SpanEnrichmentGate { + + private SpanEnrichmentGate() {} + + static boolean isEnabled() { + try { + return ConfigProvider.getInstance() + .getBoolean(FeatureFlaggingConfig.EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED, false); + } catch (final Throwable t) { + return false; // never let config reading break construction + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java new file mode 100644 index 00000000000..40e998a6546 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java @@ -0,0 +1,149 @@ +package datadog.trace.api.openfeature; + +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.SpanEnrichmentEvent; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OpenFeature {@code finally} hook that captures feature-flag evaluation metadata for APM span + * enrichment. Registered from {@link Provider#getProviderHooks()} only when the gate is on. For + * each relevant evaluation it dispatches a {@link SpanEnrichmentEvent} onto {@link + * FeatureFlaggingGateway}; the agent-side write tier resolves the active local-root span and + * accumulates the per-trace state that is flushed onto the root when the trace completes. + * + *

Capture branch (frozen Node reference): + * + *

    + *
  • serial id present → dispatch a serial-id event (the write side records the serial id, plus + * the subject when {@code __dd_do_log} AND a targeting key) + *
  • else variant missing (runtime default) → dispatch a runtime-default event with the value + * unwrapped to a native Java type + *
+ * + *

All work is wrapped in try/catch — enrichment must NEVER break flag evaluation. + */ +class SpanEnrichmentHook implements Hook { + + private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentHook.class); + + // The metadata keys the DDEvaluator attaches for span enrichment (single source of truth there). + static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; + static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; + + @Override + public void finallyAfter( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + if (details == null) { + return; + } + try { + final ImmutableMetadata metadata = details.getFlagMetadata(); + final Integer serialId = metadata != null ? metadata.getInteger(METADATA_SERIAL_ID) : null; + if (serialId != null) { + final boolean doLog = Boolean.TRUE.equals(metadata.getBoolean(METADATA_DO_LOG)); + FeatureFlaggingGateway.dispatch( + SpanEnrichmentEvent.serialId(serialId, doLog, targetingKey(ctx))); + } else if (details.getVariant() == null) { + // Runtime-default detection = MISSING VARIANT (never a reason enum). Unwrap any OpenFeature + // Value to a native Java type here so the seam carries only JDK types. + FeatureFlaggingGateway.dispatch( + SpanEnrichmentEvent.runtimeDefault( + details.getFlagKey(), unwrapDefaultValue(details.getValue()))); + } + } catch (final Throwable t) { + // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. + log.debug("Span-enrichment capture failed for flag {}", details.getFlagKey(), t); + } + } + + private static String targetingKey(final HookContext ctx) { + if (ctx == null) { + return null; + } + final EvaluationContext evaluationContext = ctx.getCtx(); + if (evaluationContext == null) { + return null; + } + final String key = evaluationContext.getTargetingKey(); + return (key == null || key.isEmpty()) ? null : key; + } + + /** + * Unwraps an OpenFeature {@link Value} to a native Java representation so the runtime default can + * cross the (JDK-types-only) seam and be JSON-serialized on the write side exactly like Node's + * {@code JSON.stringify}. Non-{@code Value} inputs (already native) are returned as-is. + */ + static Object unwrapDefaultValue(final Object value) { + return value instanceof Value ? unwrapValue((Value) value) : value; + } + + /** + * Recursively unwraps an OpenFeature {@link Value} into its native Java representation: + * structures become {@code Map}, lists become {@code List}, and scalars + * become their boxed value (or {@code null}). Nested {@link Value}s are unwrapped at every level + * so a structure containing further structures/lists serializes correctly. + */ + private static Object unwrapValue(final Value value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isStructure()) { + final Structure structure = value.asStructure(); + final Map map = new LinkedHashMap<>(); + if (structure != null) { + for (final String key : structure.keySet()) { + map.put(key, unwrapValue(structure.getValue(key))); + } + } + return map; + } + if (value.isList()) { + final List list = value.asList(); + final List out = new ArrayList<>(list == null ? 0 : list.size()); + if (list != null) { + for (final Value element : list) { + out.add(unwrapValue(element)); + } + } + return out; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isString()) { + return value.asString(); + } + if (value.isNumber()) { + // Preserve integral vs fractional so the rendered JSON number matches Node. + final Double d = value.asDouble(); + if (d != null && d == Math.rint(d) && !Double.isInfinite(d)) { + final Integer i = value.asInteger(); + if (i != null) { + return i; + } + } + return d; + } + final Instant instant = value.asInstant(); + if (instant != null) { + return instant.toString(); + } + // Unknown shape: fall back to the wrapped object's own representation. + return value.asObject(); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java new file mode 100644 index 00000000000..2ce0931e116 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java @@ -0,0 +1,241 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.SpanEnrichmentEvent; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Capture-side unit suite for APM feature-flag span enrichment. The hook dispatches a {@link + * SpanEnrichmentEvent} onto {@link FeatureFlaggingGateway} and the agent-side write tier does the + * accumulation, so these tests assert the dispatched events (not span tags — those are covered in + * {@code feature-flagging-lib}) plus the {@link Provider} gating. + */ +class SpanEnrichmentHookTest { + + private final List captured = new ArrayList<>(); + private final FeatureFlaggingGateway.SpanEnrichmentListener listener = captured::add; + + @BeforeEach + void register() { + FeatureFlaggingGateway.addSpanEnrichmentListener(listener); + } + + @AfterEach + void deregister() { + FeatureFlaggingGateway.removeSpanEnrichmentListener(listener); + } + + // ---- helpers ---- + + private static FlagEvaluationDetails details( + final String flagKey, + final String variant, + final Object value, + final ImmutableMetadata metadata) { + return FlagEvaluationDetails.builder() + .flagKey(flagKey) + .variant(variant) + .value(value) + .flagMetadata(metadata) + .build(); + } + + private static ImmutableMetadata metadata(final Integer serialId, final boolean doLog) { + final ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + if (serialId != null) { + builder.addInteger(SpanEnrichmentHook.METADATA_SERIAL_ID, serialId); + } + builder.addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, doLog); + return builder.build(); + } + + private static HookContext ctx(final String flagKey, final String targetingKey) { + return HookContext.from( + flagKey, + FlagValueType.STRING, + null, + null, + targetingKey == null ? new ImmutableContext() : new ImmutableContext(targetingKey), + "default"); + } + + // ---- serial-id branch ---- + + @Test + void serialIdWithDoLogDispatchesSerialAndSubject() { + new SpanEnrichmentHook() + .finallyAfter( + ctx("flag", "user-1"), + details("flag", "on", "v", metadata(42, true)), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertTrue(event.hasSerialId()); + assertEquals(42, event.serialId()); + assertTrue(event.doLog()); + assertEquals("user-1", event.targetingKey()); + } + + @Test + void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { + new SpanEnrichmentHook() + .finallyAfter( + ctx("flag", "user-1"), + details("flag", "on", "v", metadata(7, false)), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertTrue(event.hasSerialId()); + assertEquals(7, event.serialId()); + assertFalse( + event.doLog(), "doLog=false must be carried through so the write side skips subject"); + } + + @Test + void wrongTypedSerialIdDispatchesNothing() { + // Defensive: a non-integer value under the serial-id key (wrong type) is ignored, not crashed. + final ImmutableMetadata bad = + ImmutableMetadata.builder() + .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") + .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) + .build(); + new SpanEnrichmentHook() + .finallyAfter( + ctx("flag", "user-1"), details("flag", "on", "v", bad), Collections.emptyMap()); + assertTrue(captured.isEmpty(), "a wrong-typed serial id must never break eval or dispatch"); + } + + // ---- runtime-default branch (missing variant) ---- + + @Test + void missingVariantDispatchesRuntimeDefaultWithNativeMap() { + final Map objectValue = Collections.singletonMap("k", "val"); + new SpanEnrichmentHook() + .finallyAfter( + ctx("obj-flag", "user-1"), + details("obj-flag", null, objectValue, ImmutableMetadata.builder().build()), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final SpanEnrichmentEvent event = captured.get(0); + assertFalse(event.hasSerialId()); + assertEquals("obj-flag", event.flagKey()); + assertEquals( + objectValue, event.defaultValue(), "a native map default passes through unchanged"); + } + + @Test + void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { + final Map inner = new LinkedHashMap<>(); + inner.put("enabled", new Value(true)); + final Value structureDefault = new Value(new ImmutableStructure(inner)); + + new SpanEnrichmentHook() + .finallyAfter( + ctx("struct-flag", "user-1"), + details("struct-flag", null, structureDefault, ImmutableMetadata.builder().build()), + Collections.emptyMap()); + + assertEquals(1, captured.size()); + final Object value = captured.get(0).defaultValue(); + // Crucially a native Map, NOT an OpenFeature Value — the seam carries only JDK types. + final Map asMap = assertInstanceOf(Map.class, value); + assertEquals(Boolean.TRUE, asMap.get("enabled")); + } + + // ---- unwrapDefaultValue (the Value -> native conversion) ---- + + @Test + void unwrapConvertsValueScalarsToNative() { + assertEquals("hello", SpanEnrichmentHook.unwrapDefaultValue(new Value("hello"))); + assertEquals(Boolean.TRUE, SpanEnrichmentHook.unwrapDefaultValue(new Value(true))); + assertEquals(7, SpanEnrichmentHook.unwrapDefaultValue(new Value(7))); + assertNull(SpanEnrichmentHook.unwrapDefaultValue(new Value())); + } + + @Test + void unwrapConvertsValueListToNativeList() { + final Value listDefault = + new Value(Arrays.asList(new Value("a"), new Value(2), new Value(true))); + final Object out = SpanEnrichmentHook.unwrapDefaultValue(listDefault); + assertEquals(Arrays.asList("a", 2, Boolean.TRUE), out); + } + + @Test + void unwrapPassesNativeValuesThrough() { + final Map native0 = Collections.singletonMap("a", "b"); + assertEquals(native0, SpanEnrichmentHook.unwrapDefaultValue(native0)); + assertEquals("plain", SpanEnrichmentHook.unwrapDefaultValue("plain")); + assertNull(SpanEnrichmentHook.unwrapDefaultValue(null)); + } + + // ---- error isolation ---- + + @Test + void nullDetailsDispatchesNothing() { + new SpanEnrichmentHook().finallyAfter(null, null, null); + assertTrue(captured.isEmpty()); + } + + // ---- Provider gating ---- + + @Test + void gateOffConstructsNoHook() { + final Provider provider = new Provider(new Provider.Options(), null, Boolean.FALSE); + assertNull(provider.spanEnrichmentHook(), "gate off => no span-enrichment hook"); + for (final Hook hook : provider.getProviderHooks()) { + assertFalse( + hook instanceof SpanEnrichmentHook, "gate off => SpanEnrichmentHook never registered"); + } + } + + @Test + void gateOnConstructsHookAndRegistersItInProviderHooks() { + final Provider provider = new Provider(new Provider.Options(), null, Boolean.TRUE); + assertTrue(provider.spanEnrichmentHook() != null, "gate on => hook constructed"); + boolean registered = false; + for (final Hook hook : provider.getProviderHooks()) { + if (hook instanceof SpanEnrichmentHook) { + registered = true; + } + } + assertTrue(registered, "gate on => SpanEnrichmentHook registered in getProviderHooks"); + } + + @Test + void getProviderHooksReturnsSameInstanceEachCall() { + final Provider gateOff = new Provider(new Provider.Options(), null, Boolean.FALSE); + assertTrue( + gateOff.getProviderHooks() == gateOff.getProviderHooks(), + "gate off => getProviderHooks allocates nothing (same instance)"); + + final Provider gateOn = new Provider(new Provider.Options(), null, Boolean.TRUE); + assertTrue( + gateOn.getProviderHooks() == gateOn.getProviderHooks(), + "gate on => getProviderHooks allocates nothing (same instance)"); + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java index b9d73ffa7ab..2704b4be341 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java @@ -13,8 +13,12 @@ public interface ConfigListener extends Consumer {} public interface ExposureListener extends Consumer {} + public interface SpanEnrichmentListener extends Consumer {} + private static final List CONFIG_LISTENERS = new CopyOnWriteArrayList<>(); private static final List EXPOSURE_LISTENERS = new CopyOnWriteArrayList<>(); + private static final List SPAN_ENRICHMENT_LISTENERS = + new CopyOnWriteArrayList<>(); private static final AtomicReference CURRENT_CONFIG = new AtomicReference<>(); @@ -49,4 +53,16 @@ public static void removeExposureListener(final ExposureListener listener) { public static void dispatch(final ExposureEvent event) { EXPOSURE_LISTENERS.forEach(listener -> listener.accept(event)); } + + public static void addSpanEnrichmentListener(final SpanEnrichmentListener listener) { + SPAN_ENRICHMENT_LISTENERS.add(listener); + } + + public static void removeSpanEnrichmentListener(final SpanEnrichmentListener listener) { + SPAN_ENRICHMENT_LISTENERS.remove(listener); + } + + public static void dispatch(final SpanEnrichmentEvent event) { + SPAN_ENRICHMENT_LISTENERS.forEach(listener -> listener.accept(event)); + } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java new file mode 100644 index 00000000000..a7518c71743 --- /dev/null +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java @@ -0,0 +1,82 @@ +package datadog.trace.api.featureflag; + +/** + * Bootstrap-classloader carrier for a single feature-flag evaluation that must be reflected onto + * the local-root APM span (span enrichment). It crosses the app-classloader → agent-classloader + * boundary through {@link FeatureFlaggingGateway}, so it holds only JDK types — never an + * OpenFeature or tracer type — exactly like the sibling exposure/config payloads. + * + *

The capture side (the published {@code dd-openfeature} provider) decides which of the two + * shapes applies and unwraps any OpenFeature {@code Value} to its native Java form before + * dispatching; the write side (agent-side listener) resolves the active local root and accumulates. + * + *

    + *
  • serial-id ({@link #serialId(int, boolean, String)}) — a UFC split with a serial id, + * plus the {@code doLog} flag and the (optional) targeting key used to record the subject. + *
  • runtime-default ({@link #runtimeDefault(String, Object)}) — a flag that resolved to + * its runtime default (missing variant); carries the flag key and the native default value. + *
+ */ +public final class SpanEnrichmentEvent { + + private final boolean serialIdPresent; + private final int serialId; + private final boolean doLog; + private final String targetingKey; + private final String flagKey; + private final Object defaultValue; + + private SpanEnrichmentEvent( + final boolean serialIdPresent, + final int serialId, + final boolean doLog, + final String targetingKey, + final String flagKey, + final Object defaultValue) { + this.serialIdPresent = serialIdPresent; + this.serialId = serialId; + this.doLog = doLog; + this.targetingKey = targetingKey; + this.flagKey = flagKey; + this.defaultValue = defaultValue; + } + + /** A UFC split evaluation carrying a serial id (and, when {@code doLog}, a subject). */ + public static SpanEnrichmentEvent serialId( + final int serialId, final boolean doLog, final String targetingKey) { + return new SpanEnrichmentEvent(true, serialId, doLog, targetingKey, null, null); + } + + /** + * A runtime-default evaluation (missing variant). {@code value} must already be unwrapped to a + * native Java type (Map/List/scalar/null) by the caller. + */ + public static SpanEnrichmentEvent runtimeDefault(final String flagKey, final Object value) { + return new SpanEnrichmentEvent(false, 0, false, null, flagKey, value); + } + + /** True for the serial-id shape; false for the runtime-default shape. */ + public boolean hasSerialId() { + return serialIdPresent; + } + + public int serialId() { + return serialId; + } + + public boolean doLog() { + return doLog; + } + + public String targetingKey() { + return targetingKey; + } + + public String flagKey() { + return flagKey; + } + + public Object defaultValue() { + return defaultValue; + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java index 1782032278d..37f6909eeea 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java @@ -7,11 +7,19 @@ public class Split { public final List shards; public final String variationKey; public final Map extraLogging; + // Nullable Integer (not primitive int): the serialId is absent in some UFC shapes. Populated by + // Moshi reflective deserialization from the UFC "serialId" JSON field. Surfaced as + // __dd_split_serial_id in eval metadata for APM span enrichment. + public final Integer serialId; public Split( - final List shards, final String variationKey, final Map extraLogging) { + final List shards, + final String variationKey, + final Map extraLogging, + final Integer serialId) { this.shards = shards; this.variationKey = variationKey; this.extraLogging = extraLogging; + this.serialId = serialId; } } diff --git a/products/feature-flagging/feature-flagging-config/build.gradle.kts b/products/feature-flagging/feature-flagging-config/build.gradle.kts new file mode 100644 index 00000000000..14109d8dfd9 --- /dev/null +++ b/products/feature-flagging/feature-flagging-config/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `java-library` +} + +apply(from = "$rootDir/gradle/java.gradle") + +description = "Feature flagging configuration keys (compile-time constants)" + +extra["excludedClassesCoverage"] = listOf( + // Constants-only holder — no executable logic to cover. + "datadog.trace.api.featureflag.config.FeatureFlaggingConfig", +) diff --git a/products/feature-flagging/feature-flagging-config/gradle.lockfile b/products/feature-flagging/feature-flagging-config/gradle.lockfile new file mode 100644 index 00000000000..086d0fde3c0 --- /dev/null +++ b/products/feature-flagging/feature-flagging-config/gradle.lockfile @@ -0,0 +1,75 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :products:feature-flagging:feature-flagging-config:dependencies --write-locks +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.9.8=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.13.2=spotbugs +com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-io:commons-io:2.20.0=spotbugs +de.thetaphi:forbiddenapis:3.10=compileClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +jaxen:jaxen:2.0.0=spotbugs +net.bytebuddy:byte-buddy-agent:1.12.8=testRuntimeClasspath +net.bytebuddy:byte-buddy:1.12.8=testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.9=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-lang3:3.19.0=spotbugs +org.apache.commons:commons-text:1.14.0=spotbugs +org.apache.logging.log4j:log4j-api:2.25.2=spotbugs +org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.14=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.14=jacocoAnt +org.jacoco:org.jacoco.core:0.8.14=jacocoAnt +org.jacoco:org.jacoco.report:0.8.14=jacocoAnt +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.0=spotbugs +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.9=spotbugs +org.ow2.asm:asm-commons:9.9=jacocoAnt,spotbugs +org.ow2.asm:asm-tree:9.9=jacocoAnt,spotbugs +org.ow2.asm:asm-util:9.9=spotbugs +org.ow2.asm:asm:9.9=jacocoAnt,spotbugs +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,runtimeClasspath,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java new file mode 100644 index 00000000000..9d75961e9d4 --- /dev/null +++ b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java @@ -0,0 +1,14 @@ +package datadog.trace.api.featureflag.config; + +public class FeatureFlaggingConfig { + + public static final String FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; + + /** + * Opt-in gate for APM span enrichment with feature-flag evaluation metadata. DISTINCT from {@link + * #FLAGGING_PROVIDER_ENABLED} and OFF by default — enabling the provider does not enable span + * enrichment. + */ + public static final String EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED = + "experimental.flagging.provider.span.enrichment.enabled"; +} diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 3291e239d40..9d659d6ed63 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -22,9 +22,14 @@ dependencies { api(project(":utils:queue-utils")) compileOnly(project(":dd-trace-core")) // shading does not work with this one + // Span-enrichment write tier: TraceInterceptor / GlobalTracer / AgentTracer / AgentSpan. + compileOnly(project(":internal-api")) + // Platform JSON writer for the ffe_* tag values. + compileOnly(project(":components:json")) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) + testImplementation(project(":internal-api")) testImplementation(project(":utils:test-utils")) testImplementation(project(":dd-java-agent:testing")) } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java new file mode 100644 index 00000000000..a14274432b0 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentAccumulator.java @@ -0,0 +1,257 @@ +package com.datadog.featureflag; + +import datadog.json.JsonWriter; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Per-local-root-span accumulator for APM feature-flag span enrichment. + * + *

Holds the serial ids, hashed subjects, and runtime defaults captured during flag evaluation + * for a single local trace fragment. The limits, dedupe semantics, truncation, and output tag + * shapes are FROZEN against the Node reference ({@code dd-trace-js#8343}) — see {@link + * ULeb128Encoder}. + * + *

Instances are created lazily and held in a {@link SpanEnrichmentStates} store, keyed by the + * local-root span object. The agent-side {@link SpanEnrichmentWriter} writes (from the flag-eval + * seam); the write interceptor ({@link SpanEnrichmentInterceptor}) reads and clears. When the + * span-enrichment gate is off, no seam events are dispatched, so no store and no accumulator are + * ever created and there is no idle per-span overhead. + * + *

Runtime-default values arrive already unwrapped to native Java types (the capture side unwraps + * any OpenFeature {@code Value} before crossing the seam), so this class has no OpenFeature + * dependency. + * + *

Output tag shapes: + * + *

    + *
  • {@code ffe_flags_enc} — a bare base64 string (delta-varint of the serial ids) + *
  • {@code ffe_subjects_enc} — a JSON object string {@code {"": "", ...}} + *
  • {@code ffe_runtime_defaults} — a JSON object string {@code {"": "", ...}} + *
+ */ +final class SpanEnrichmentAccumulator { + + static final int MAX_SERIAL_IDS = 200; + static final int MAX_SUBJECTS = 10; + static final int MAX_EXPERIMENTS_PER_SUBJECT = 20; + static final int MAX_DEFAULTS = 5; + static final int MAX_DEFAULT_VALUE_LENGTH = 64; + + static final String TAG_FLAGS_ENC = "ffe_flags_enc"; + static final String TAG_SUBJECTS_ENC = "ffe_subjects_enc"; + static final String TAG_RUNTIME_DEFAULTS = "ffe_runtime_defaults"; + + // dedupe is structural (a Set); sorted for deterministic encoding. + private final TreeSet serialIds = new TreeSet<>(); + // sha256hex(targetingKey) -> serial ids. LinkedHashMap for stable iteration order. + private final Map> subjects = new LinkedHashMap<>(); + // flagKey -> value string (first-wins, truncated to MAX_DEFAULT_VALUE_LENGTH). + private final Map defaults = new LinkedHashMap<>(); + + /** Adds a serial id, dropping silently once {@link #MAX_SERIAL_IDS} is reached. */ + synchronized void addSerialId(final int id) { + if (serialIds.size() >= MAX_SERIAL_IDS && !serialIds.contains(id)) { + return; + } + serialIds.add(id); + } + + /** + * Records that the given targeting key was exposed to the experiment identified by {@code id}. + * The targeting key is SHA-256-hashed before storage. Enforces both the subject cap ({@link + * #MAX_SUBJECTS}) and the per-subject experiment cap ({@link #MAX_EXPERIMENTS_PER_SUBJECT}). + */ + synchronized void addSubject(final String targetingKey, final int id) { + if (targetingKey == null) { + return; + } + final String hashed = ULeb128Encoder.hashTargetingKey(targetingKey); + final TreeSet existing = subjects.get(hashed); + if (existing != null) { + if (existing.size() >= MAX_EXPERIMENTS_PER_SUBJECT && !existing.contains(id)) { + return; + } + existing.add(id); + return; + } + if (subjects.size() >= MAX_SUBJECTS) { + return; + } + final TreeSet ids = new TreeSet<>(); + ids.add(id); + subjects.put(hashed, ids); + } + + /** + * Records a runtime-default value for {@code flagKey} (first-wins). Structured values (Map/List) + * are serialized to JSON (NOT {@code toString()}); the result is truncated to {@link + * #MAX_DEFAULT_VALUE_LENGTH}. + */ + synchronized void addDefault(final String flagKey, final Object value) { + if (flagKey == null) { + return; + } + if (defaults.containsKey(flagKey)) { + return; // first-wins + } + if (defaults.size() >= MAX_DEFAULTS) { + return; + } + String valueStr = stringifyDefault(value); + if (valueStr.length() > MAX_DEFAULT_VALUE_LENGTH) { + valueStr = utf8SafeTruncate(valueStr, MAX_DEFAULT_VALUE_LENGTH); + } + defaults.put(flagKey, valueStr); + } + + /** + * @return true when there is at least one serial id or runtime default to write. Subjects are not + * checked because a subject is never recorded without its serial id. + */ + synchronized boolean hasData() { + return !serialIds.isEmpty() || !defaults.isEmpty(); + } + + /** + * Builds the {@code ffe_*} span tags from the accumulated state. Empty groups are omitted. + * + * @return a map of tag name to tag value (a subset of {@code ffe_flags_enc}, {@code + * ffe_subjects_enc}, {@code ffe_runtime_defaults}) + */ + synchronized Map toSpanTags() { + final Map tags = new LinkedHashMap<>(); + if (!serialIds.isEmpty()) { + final String encoded = ULeb128Encoder.encodeDeltaVarint(serialIds); + if (!encoded.isEmpty()) { + tags.put(TAG_FLAGS_ENC, encoded); + } + } + if (!subjects.isEmpty()) { + final Map encodedSubjects = new LinkedHashMap<>(); + for (final Map.Entry> entry : subjects.entrySet()) { + encodedSubjects.put(entry.getKey(), ULeb128Encoder.encodeDeltaVarint(entry.getValue())); + } + tags.put(TAG_SUBJECTS_ENC, toJsonObject(encodedSubjects)); + } + if (!defaults.isEmpty()) { + tags.put(TAG_RUNTIME_DEFAULTS, toJsonObject(defaults)); + } + return tags; + } + + // ---- helpers (visible for tests) ---- + + /** + * Mirrors the Node {@code (typeof value === 'object' && value !== null) ? JSON.stringify(value) : + * String(value)} rule: structured values (Map/List/array) are JSON-stringified; scalars use their + * string form; {@code null} becomes the bare {@code null}. + * + *

The value has already been unwrapped to a native Java type by the capture side (any + * OpenFeature {@code Value} is converted to Map/List/scalar before the seam), so no OpenFeature + * type ever reaches here. + */ + static String stringifyDefault(final Object value) { + if (value == null) { + return "null"; + } + if (value instanceof Map || value instanceof Iterable || value.getClass().isArray()) { + return toJsonValue(value); + } + if (value instanceof CharSequence || value instanceof Character) { + return value.toString(); + } + // Numbers / booleans — their string form matches what Node's String(value) emits for these + // scalar cases. + return String.valueOf(value); + } + + /** UTF-8-safe truncation: never split a surrogate pair at the {@code maxChars} boundary. */ + static String utf8SafeTruncate(final String value, final int maxChars) { + if (value.length() <= maxChars) { + return value; + } + int end = maxChars; + if (Character.isHighSurrogate(value.charAt(end - 1))) { + end--; // drop the dangling high surrogate rather than emit a broken pair + } + return value.substring(0, end); + } + + /** + * Serializes a String->String map to a compact JSON object string using the platform {@link + * JsonWriter}. + * + *

Consumers of these tags parse them as JSON (the backend enricher via Jackson, the parametric + * system-tests via {@code json.loads}), so the writer's escaping (e.g. {@code /} → {@code \/}, + * non-ASCII → {@code \\uXXXX}) is round-trip-equivalent and byte-parity with the JS reference is + * not required. + */ + static String toJsonObject(final Map map) { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + for (final Map.Entry entry : map.entrySet()) { + writer.name(entry.getKey()).value(entry.getValue()); + } + writer.endObject(); + return writer.toString(); + } + } + + private static String toJsonValue(final Object value) { + try (JsonWriter writer = new JsonWriter()) { + writeJsonValue(writer, value); + return writer.toString(); + } + } + + @SuppressWarnings("unchecked") + private static void writeJsonValue(final JsonWriter writer, final Object value) { + // Callers pass values already unwrapped to native form by the capture side, so no OpenFeature + // Value ever reaches here. + if (value == null) { + writer.nullValue(); + } else if (value instanceof Map) { + writer.beginObject(); + for (final Map.Entry entry : ((Map) value).entrySet()) { + writer.name(String.valueOf(entry.getKey())); + writeJsonValue(writer, entry.getValue()); + } + writer.endObject(); + } else if (value instanceof Iterable) { + writer.beginArray(); + for (final Object element : (Iterable) value) { + writeJsonValue(writer, element); + } + writer.endArray(); + } else if (value instanceof Boolean) { + writer.value((Boolean) value); + } else if (value instanceof Integer + || value instanceof Long + || value instanceof Short + || value instanceof Byte) { + writer.value(((Number) value).longValue()); + } else if (value instanceof Number) { + writer.value(((Number) value).doubleValue()); + } else { + // CharSequence / Character / anything else → string form. + writer.value(value.toString()); + } + } + + // ---- test-only accessors ---- + + synchronized Set serialIdsView() { + return new TreeSet<>(serialIds); + } + + synchronized int subjectCount() { + return subjects.size(); + } + + synchronized int defaultCount() { + return defaults.size(); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java new file mode 100644 index 00000000000..672d884d30f --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentInterceptor.java @@ -0,0 +1,135 @@ +package com.datadog.featureflag; + +import datadog.trace.api.interceptor.MutableSpan; +import datadog.trace.api.interceptor.TraceInterceptor; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.util.Collection; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link TraceInterceptor} that writes the accumulated {@code ffe_*} tags onto the local root span + * when a trace actually completes. This is the WRITE half of the capture-vs-write split — + * the CAPTURE half is the agent-side {@link SpanEnrichmentWriter}, which fills the {@link + * SpanEnrichmentStates} store from flag-evaluation seam events. + * + *

Single agent-side owner

+ * + *

Exactly one interceptor is created and owned by the {@link SpanEnrichmentWriter} (agent + * classloader) and registered with the tracer at most once, lazily on the first enrichment event. + * Because the owner lives in the stable agent classloader — not per-provider in an application + * classloader — there is no rebinding, no reconfiguration hazard, and no application-classloader + * pinning. + * + *

Partial-flush correctness

+ * + *

{@code dd-trace-core} runs {@code onTraceComplete} on every flush, not only on final + * trace completion: {@code CoreTracer.write(SpanList)} → {@code interceptCompleteTrace(...)} fires + * for both partial flushes ({@code PendingTrace.partialFlush()} → {@code write(true)}) and the + * final write ({@code write(false)}). A partial flush deliberately excludes the + * still-open local root — {@code PendingTrace} holds the root back ({@code rootSpanWritten}) + * and {@code getRootSpan()} returns null on a partial fragment, so {@code CoreTracer.write} does + * not invoke {@code onRootSpanFinished} for it. + * + *

Therefore this interceptor flushes+removes state only when the local root span is present + * in the flushed collection (i.e. this is the final write for the trace). On a partial flush + * the root is absent, so we return early and keep the accumulator intact, preserving every + * flag evaluated before the flush boundary. Without this guard, the first partial flush would drain + * the accumulator and write tags onto a not-yet-finished root, silently dropping all pre-flush + * enrichment — exactly the long-running-trace data-loss bug. + * + *

The store is weak-keyed by the local-root span, so a trace that never reaches this interceptor + * is collected with its root and cannot leak unboundedly. + * + *

All work is wrapped in try/catch — enrichment must NEVER break trace finish. + */ +final class SpanEnrichmentInterceptor implements TraceInterceptor { + + private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentInterceptor.class); + + /** + * Unique priority in the "trace data enrichment" band, after {@code GIT_METADATA} (3) and before + * the custom-sampling band ({@code Integer.MAX_VALUE - 2}). Distinct from every value in {@code + * AbstractTraceInterceptor.Priority} and from the CI Visibility interceptors. + */ + static final int PRIORITY = 4; + + private final SpanEnrichmentStates states; + + SpanEnrichmentInterceptor(final SpanEnrichmentStates states) { + this.states = states; + } + + @Override + public Collection onTraceComplete( + final Collection trace) { + try { + // Fast path: no accumulated state at all → skip the per-flush scan + lock entirely. This is + // the common case for services that never evaluate a flag on a given trace. + if (trace == null || trace.isEmpty() || states.isEmpty()) { + return trace; + } + // Resolve the local root for this fragment, then require that the root is actually PRESENT in + // this collection. A partial flush excludes the still-open root, so its absence means "not + // the final write" — keep the accumulator and bail. + final MutableSpan localRoot = findLocalRootInFragment(trace); + if (!(localRoot instanceof AgentSpan)) { + return trace; // partial flush, or no resolvable in-fragment root: keep state untouched + } + // Key by the local-root span object to match the capture-side keying. + final SpanEnrichmentAccumulator state = states.remove((AgentSpan) localRoot); + if (state == null || !state.hasData()) { + return trace; + } + for (final Map.Entry tag : state.toSpanTags().entrySet()) { + final String value = tag.getValue(); + if (value != null && !value.isEmpty()) { + localRoot.setTag(tag.getKey(), value); + } + } + } catch (final Throwable t) { + // Never let span enrichment break trace finish; a debug line aids diagnosis if it does. + log.debug("Span-enrichment tag write failed", t); + } + return trace; + } + + /** + * Resolves the local root span for this fragment and returns it ONLY if it is actually present in + * the fragment by reference identity. Returns {@code null} when the root is not in the collection + * (a partial flush excludes the still-open root) or when no root can be safely identified. + * + *

We never guess: a non-root span is never returned. If the first span reports a non-null + * local root, we accept it only after confirming that exact object is in the fragment; otherwise + * we look for a span that is provably its own local root and present. + */ + private static MutableSpan findLocalRootInFragment( + final Collection trace) { + final MutableSpan first = trace.iterator().next(); + final MutableSpan candidate = first.getLocalRootSpan(); + if (candidate != null) { + // Accept the reported local root only if it is genuinely part of THIS fragment. On a partial + // flush the root is reachable by reference but NOT in the collection → reject (keep state). + for (final MutableSpan span : trace) { + if (span == candidate) { + return candidate; + } + } + return null; // root excluded from this fragment → partial flush, do not flush/remove + } + // Local root unknown for the first span: only accept a span that is provably its own local root + // and present here. Never fall back to an arbitrary span. + for (final MutableSpan span : trace) { + if (span.getLocalRootSpan() == span) { + return span; + } + } + return null; + } + + @Override + public int priority() { + return PRIORITY; + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java new file mode 100644 index 00000000000..7231466cd1b --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentStates.java @@ -0,0 +1,69 @@ +package com.datadog.featureflag; + +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Store of per-trace {@link SpanEnrichmentAccumulator} state, keyed by the local-root span object. + * + *

Weak keys. The map is a {@link WeakHashMap} keyed by the local-root {@link AgentSpan} + * instance. The accumulator is reachable only while its local-root span is, so a trace that never + * reaches the interceptor (dropped, Noop tracer, never-finishing root) is collected together with + * its root — it cannot leak unboundedly. No cap, FIFO eviction, or cleanup thread is required; + * {@code WeakHashMap} purges stale entries on access. + * + *

Identity keying. {@code DDSpan} does not override {@code equals}/{@code hashCode}, so + * the map keys by object identity. Keying by the local-root span object (rather than the 128-bit + * trace-id hex string) is inherently unique per trace: two distinct traces have distinct root + * objects and can never share an accumulator. + * + *

A single store is owned by the agent-side {@link SpanEnrichmentWriter} and shared with its + * {@link SpanEnrichmentInterceptor}. The writer accumulates (from the flag-eval seam); the + * interceptor reads + removes (trace-write thread). + * + *

Thread-safety: all access is guarded by the intrinsic lock on this instance. The writer writes + * (eval thread) and the interceptor reads+removes (trace-write thread) concurrently, so every + * mutator and accessor synchronizes. Contention is low — operations are O(1) map touches. + */ +final class SpanEnrichmentStates { + + // Weak keys: the accumulator is GC'd with its local-root span, so a trace that never completes + // cannot leak. Identity-keyed by the local-root AgentSpan object. guarded by 'this'. + private final Map states = new WeakHashMap<>(); + + /** Returns the accumulator for {@code root}, creating (and inserting) it if absent. */ + synchronized SpanEnrichmentAccumulator getOrCreate(final AgentSpan root) { + SpanEnrichmentAccumulator existing = states.get(root); + if (existing != null) { + return existing; + } + final SpanEnrichmentAccumulator created = new SpanEnrichmentAccumulator(); + states.put(root, created); + return created; + } + + /** Removes and returns the accumulator for {@code root}, or {@code null} if absent. */ + synchronized SpanEnrichmentAccumulator remove(final AgentSpan root) { + return states.remove(root); + } + + /** Clears all tracked state (cleanup on shutdown). */ + synchronized void clear() { + states.clear(); + } + + synchronized int size() { + return states.size(); + } + + synchronized boolean isEmpty() { + return states.isEmpty(); + } + + // ---- test-only accessor ---- + + synchronized SpanEnrichmentAccumulator peek(final AgentSpan root) { + return states.get(root); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java new file mode 100644 index 00000000000..96c75074edb --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/SpanEnrichmentWriter.java @@ -0,0 +1,183 @@ +package com.datadog.featureflag; + +import datadog.trace.api.GlobalTracer; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.SpanEnrichmentEvent; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Agent-side owner of APM feature-flag span enrichment. This is the WRITE tier of the + * capture-vs-write split: it listens on {@link FeatureFlaggingGateway} for {@link + * SpanEnrichmentEvent}s dispatched by the published {@code dd-openfeature} provider during flag + * evaluation, resolves the active local-root span, and accumulates per-trace state that a {@link + * SpanEnrichmentInterceptor} later flushes onto the root when the trace completes. + * + *

Process-wide singleton (restart-safe). Use {@link #getInstance()} for the agent wiring. + * The tracer keeps trace interceptors for the life of the JVM and offers no removal API, so the + * interceptor — and the weak-keyed state it reads — must outlive any single start/stop of the + * feature-flagging subsystem. A fresh writer per {@code start()} would build a second interceptor + * at the same priority; the tracer would reject it and its state would never be read, silently + * disabling enrichment after a restart. Reusing one instance avoids that: the single interceptor is + * registered exactly once and simply resumes when {@link #init()} re-subscribes the listener. + * + *

Zero idle overhead when off. When the span-enrichment gate is off the provider adds no + * capture hook, so no seam events are dispatched, this listener never runs, and the interceptor is + * never registered — the tracer's write path is untouched. The interceptor is registered lazily on + * the first enrichment event that has an active span, so a service that enables the feature but + * never evaluates a flag on a traced request still pays nothing. + * + *

All work is wrapped in try/catch — enrichment must NEVER break flag evaluation. + */ +public final class SpanEnrichmentWriter implements FeatureFlaggingGateway.SpanEnrichmentListener { + + private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentWriter.class); + + // The one instance used by the agent. Persisting it across FeatureFlaggingSystem start/stop keeps + // the single registered interceptor (and its state) alive, so a restart never re-registers. + private static final SpanEnrichmentWriter INSTANCE = new SpanEnrichmentWriter(); + + public static SpanEnrichmentWriter getInstance() { + return INSTANCE; + } + + /** + * Resolves the local-root span for the active trace. Injectable so tests need no static mocks. + */ + interface RootSpanResolver { + AgentSpan activeLocalRoot(); + } + + /** + * Registers the interceptor with the tracer, returning {@code true} when accepted. Injectable so + * tests are deterministic without a globally-installed tracer. + */ + interface InterceptorRegistrar { + boolean register(SpanEnrichmentInterceptor interceptor); + } + + private static final RootSpanResolver DEFAULT_RESOLVER = + () -> { + final AgentSpan active = AgentTracer.activeSpan(); + if (active == null) { + return null; + } + final AgentSpan localRoot = active.getLocalRootSpan(); + return localRoot != null ? localRoot : active; + }; + + private static final InterceptorRegistrar DEFAULT_REGISTRAR = + interceptor -> GlobalTracer.get().addTraceInterceptor(interceptor); + + private final RootSpanResolver rootSpanResolver; + private final InterceptorRegistrar registrar; + private final SpanEnrichmentStates states; + private final SpanEnrichmentInterceptor interceptor; + // Registered with the tracer at most once, lazily on the first enrichment event with an active + // span. Once true it stays true for the life of this instance, so a subsystem restart (which + // reuses the singleton) never attempts a second, doomed registration. + private final AtomicBoolean interceptorRegistered = new AtomicBoolean(false); + + private SpanEnrichmentWriter() { + this(DEFAULT_RESOLVER, DEFAULT_REGISTRAR); + } + + // Visible for tests: an isolated writer (own state + interceptor) whose interceptor registration + // is assumed to succeed, bypassing the shared INSTANCE and any globally-installed tracer. + SpanEnrichmentWriter(final RootSpanResolver rootSpanResolver) { + this(rootSpanResolver, interceptor -> true); + } + + // Visible for tests: also inject the registrar to exercise the not-registered path. + SpanEnrichmentWriter( + final RootSpanResolver rootSpanResolver, final InterceptorRegistrar registrar) { + this.rootSpanResolver = rootSpanResolver; + this.registrar = registrar; + this.states = new SpanEnrichmentStates(); + this.interceptor = new SpanEnrichmentInterceptor(states); + } + + /** Starts listening for enrichment events. Safe to call again after {@link #close()}. */ + public void init() { + FeatureFlaggingGateway.addSpanEnrichmentListener(this); + } + + /** + * Stops listening and drops any residual state. The interceptor stays registered with the tracer + * (it cannot be removed) but goes inert while the state is empty; a later {@link #init()} resumes + * enrichment on the same interceptor. + */ + public void close() { + FeatureFlaggingGateway.removeSpanEnrichmentListener(this); + states.clear(); + } + + @Override + public void accept(final SpanEnrichmentEvent event) { + if (event == null) { + return; + } + try { + final AgentSpan root = rootSpanResolver.activeLocalRoot(); + if (root == null) { + return; // no active span → nothing to enrich (and nothing to register the interceptor for) + } + if (!ensureInterceptorRegistered()) { + // The interceptor isn't registered (e.g. tracer absent), so nothing would ever flush this + // state — skip accumulating. A later event retries registration. + return; + } + final SpanEnrichmentAccumulator state = states.getOrCreate(root); + if (event.hasSerialId()) { + final int serialId = event.serialId(); + state.addSerialId(serialId); + if (event.doLog() && event.targetingKey() != null) { + state.addSubject(event.targetingKey(), serialId); + } + } else if (event.flagKey() != null) { + state.addDefault(event.flagKey(), event.defaultValue()); + } + } catch (final Throwable t) { + // Never let span enrichment break flag evaluation; a debug line aids diagnosis if it does. + log.debug("Span-enrichment accumulation failed", t); + } + } + + /** + * @return true once the interceptor is registered with the tracer. + */ + private boolean ensureInterceptorRegistered() { + if (interceptorRegistered.get()) { + return true; + } + synchronized (this) { + if (interceptorRegistered.get()) { + return true; + } + try { + // register() returns false (without throwing) when the tracer rejects it — e.g. the global + // tracer is still the no-op placeholder. Only latch on success so a later event retries; + // otherwise a transient false would permanently disable enrichment. + if (registrar.register(interceptor)) { + interceptorRegistered.set(true); + } + } catch (final Throwable t) { + // Leave unregistered; a later event retries. + } + return interceptorRegistered.get(); + } + } + + // ---- test-only accessors ---- + + SpanEnrichmentStates states() { + return states; + } + + SpanEnrichmentInterceptor interceptor() { + return interceptor; + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java new file mode 100644 index 00000000000..7566cf8698d --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ULeb128Encoder.java @@ -0,0 +1,97 @@ +package com.datadog.featureflag; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * ULEB128 delta-varint + base64 codec for APM feature-flag span enrichment. + * + *

Ported VERBATIM from the frozen Node reference ({@code dd-trace-js#8343}). The tag names, + * encoding, and golden vectors are FROZEN against that contract — backend/Trino decode and the + * parametric system-tests assertions depend on exact parity, so this MUST NOT be re-derived. + * + *

Algorithm: dedupe (via {@link Set}) → sort ascending → emit the delta from the previous id as + * an unsigned LEB128 varint (7 bits/byte, MSB = continuation) → base64-encode the byte buffer. The + * empty set encodes to the empty string (the caller then omits the tag). + * + *

Golden vector: {@code {100, 108, 128, 130}} → deltas {@code [100, 8, 20, 2]} → bytes {@code + * [0x64, 0x08, 0x14, 0x02]} → base64 {@code "ZAgUAg=="}. + */ +final class ULeb128Encoder { + + private ULeb128Encoder() {} + + /** + * ULEB128 delta-varint encodes the given serial ids into a bare base64 string. + * + * @param serialIds the serial ids to encode (deduped + sorted ascending internally) + * @return the base64-encoded delta-varint bytes, or the empty string when {@code serialIds} is + * empty or null + */ + static String encodeDeltaVarint(final Set serialIds) { + if (serialIds == null || serialIds.isEmpty()) { + // Empty set encodes to the empty string; the caller omits the tag. + return ""; + } + final SortedSet sorted = + serialIds instanceof SortedSet ? (SortedSet) serialIds : new TreeSet<>(serialIds); + // Worst case: 5 bytes per 32-bit varint. + final byte[] buffer = new byte[sorted.size() * 5]; + int length = 0; + int previous = 0; + for (final Integer id : sorted) { + long delta = + ((long) id) - previous; // long to stay safe; deltas are non-negative (sorted asc) + previous = id; + while (delta > 0x7FL) { + buffer[length++] = (byte) ((delta & 0x7FL) | 0x80L); + delta >>>= 7; + } + buffer[length++] = (byte) (delta & 0x7FL); + } + final byte[] out = new byte[length]; + System.arraycopy(buffer, 0, out, 0, length); + return Base64.getEncoder().encodeToString(out); + } + + /** + * Lower-case hex SHA-256 of the given string. Used to hash subject targeting keys before they are + * emitted (privacy: subject keys are never emitted in clear text). + * + * @param value the value to hash + * @return the lower-case hex SHA-256 digest + */ + static String hashTargetingKey(final String value) { + final MessageDigest digest = SHA_256.get(); + digest.reset(); + final byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder(hash.length * 2); + for (final byte b : hash) { + final int v = b & 0xFF; + if (v < 0x10) { + hex.append('0'); + } + hex.append(Integer.toHexString(v)); + } + return hex.toString(); + } + + // Per-thread SHA-256 instance: hashing runs on every doLog=true subject capture, so a + // ThreadLocal avoids a provider lookup + allocation per call on that hot path. digest() resets + // the instance after each hash; we also reset() defensively before use. + private static final ThreadLocal SHA_256 = + ThreadLocal.withInitial( + () -> { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (final NoSuchAlgorithmException e) { + // SHA-256 is mandated by the JLS on every JVM; unreachable in practice. + throw new IllegalStateException("SHA-256 algorithm not available", e); + } + }); +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java new file mode 100644 index 00000000000..8b1ff1cee98 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentAccumulatorTest.java @@ -0,0 +1,178 @@ +package com.datadog.featureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; + +/** + * Write-tier codec + accumulator suite. The encoding, limits, and tag shapes are FROZEN against the + * Node reference ({@code dd-trace-js#8343}); the golden vector {@code {100,108,128,130} -> + * "ZAgUAg=="} anchors byte-for-byte cross-SDK parity. + */ +class SpanEnrichmentAccumulatorTest { + + // Test-only decode oracle for the ULEB128 delta-varint codec — the production code only encodes. + private static SortedSet decodeDeltaVarint(final String encoded) { + final SortedSet result = new TreeSet<>(); + if (encoded == null || encoded.isEmpty()) { + return result; + } + final byte[] bytes = Base64.getDecoder().decode(encoded); + int previous = 0; + int index = 0; + while (index < bytes.length) { + long value = 0; + int shift = 0; + while (true) { + final byte b = bytes[index++]; + value |= ((long) (b & 0x7F)) << shift; + if ((b & 0x80) == 0) { + break; + } + shift += 7; + } + previous += (int) value; + result.add(previous); + } + return result; + } + + @Test + void codecGoldenVectorAndRoundTrip() { + final SortedSet ids = new TreeSet<>(); + ids.add(100); + ids.add(108); + ids.add(128); + ids.add(130); + final String encoded = ULeb128Encoder.encodeDeltaVarint(ids); + assertEquals("ZAgUAg==", encoded, "golden vector must match the frozen Node contract"); + assertEquals(ids, decodeDeltaVarint(encoded)); + assertEquals("", ULeb128Encoder.encodeDeltaVarint(Collections.emptySet())); + final SortedSet withDup = new TreeSet<>(ids); + withDup.add(100); + assertEquals( + encoded, ULeb128Encoder.encodeDeltaVarint(withDup), "duplicates do not change bytes"); + } + + @Test + void flagsEncTagFromAccumulatedSerialIds() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + acc.addSerialId(100); + acc.addSerialId(108); + acc.addSerialId(128); + acc.addSerialId(130); + acc.addSerialId(100); // dedupe + assertTrue(acc.hasData()); + assertEquals("ZAgUAg==", acc.toSpanTags().get(SpanEnrichmentAccumulator.TAG_FLAGS_ENC)); + } + + @Test + void max200SerialIdsEnforced() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + for (int i = 0; i < 300; i++) { + acc.addSerialId(i); + } + assertEquals( + SpanEnrichmentAccumulator.MAX_SERIAL_IDS, + acc.serialIdsView().size(), + "serial ids must be capped at 200"); + } + + @Test + void subjectAndPerSubjectExperimentCaps() { + // per-subject experiment cap: 20 max + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + for (int i = 0; i < 25; i++) { + acc.addSubject("subjectX", i); + } + final SortedSet decoded = + decodeDeltaVarint( + acc.toSpanTags() + .get(SpanEnrichmentAccumulator.TAG_SUBJECTS_ENC) + .replaceAll("^\\{\"[a-f0-9]+\":\"", "") + .replaceAll("\"\\}$", "")); + assertEquals(SpanEnrichmentAccumulator.MAX_EXPERIMENTS_PER_SUBJECT, decoded.size()); + + // subject cap: 10 max distinct subjects + final SpanEnrichmentAccumulator acc2 = new SpanEnrichmentAccumulator(); + for (int i = 0; i < 15; i++) { + acc2.addSubject("subject-" + i, i); + } + assertEquals(SpanEnrichmentAccumulator.MAX_SUBJECTS, acc2.subjectCount()); + } + + @Test + void runtimeDefaultJsonStringifyAndTruncation() { + // native object -> JSON, not toString + assertEquals( + "{\"a\":\"b\"}", + SpanEnrichmentAccumulator.stringifyDefault(Collections.singletonMap("a", "b"))); + // scalar string -> as-is + assertEquals("hello", SpanEnrichmentAccumulator.stringifyDefault("hello")); + // null -> "null" + assertEquals("null", SpanEnrichmentAccumulator.stringifyDefault(null)); + // native list -> JSON array + assertEquals( + "[\"a\",2,true]", + SpanEnrichmentAccumulator.stringifyDefault(java.util.Arrays.asList("a", 2, true))); + + // 64-char truncation + final StringBuilder longValue = new StringBuilder(); + for (int i = 0; i < 100; i++) { + longValue.append('x'); + } + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + acc.addDefault("flag", longValue.toString()); + final String tag = acc.toSpanTags().get(SpanEnrichmentAccumulator.TAG_RUNTIME_DEFAULTS); + final String expectedValue = + longValue.substring(0, SpanEnrichmentAccumulator.MAX_DEFAULT_VALUE_LENGTH); + assertEquals("{\"flag\":\"" + expectedValue + "\"}", tag); + + // first-wins + acc.addDefault("flag", "second"); + assertEquals(1, acc.defaultCount()); + } + + @Test + void jsonUsesPlatformWriterEscaping() { + // The platform JsonWriter escapes '/' as backslash-slash and non-ASCII as a backslash-u escape. + // That differs from the JS reference bytes but is round-trip-equivalent: all consumers + // JSON-parse these tags (backend Jackson, system-tests json.loads), so byte-parity is not + // required. '/' matters in practice because ffe_subjects_enc values are base64 (may contain + // '/'). + assertEquals( + "{\"h\":\"a\\/b\"}", + SpanEnrichmentAccumulator.toJsonObject(Collections.singletonMap("h", "a/b"))); + assertEquals( + "{\"k\":\"caf\\u00E9\"}", + SpanEnrichmentAccumulator.toJsonObject(Collections.singletonMap("k", "café"))); + // nested structured runtime default goes through the same writer + assertEquals( + "{\"a\":\"x\\/y\"}", + SpanEnrichmentAccumulator.stringifyDefault(Collections.singletonMap("a", "x/y"))); + } + + @Test + void maxDefaultsEnforced() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + for (int i = 0; i < 10; i++) { + acc.addDefault("flag-" + i, "v"); + } + assertEquals(SpanEnrichmentAccumulator.MAX_DEFAULTS, acc.defaultCount()); + } + + @Test + void noDataWhenEmpty() { + final SpanEnrichmentAccumulator acc = new SpanEnrichmentAccumulator(); + assertFalse(acc.hasData()); + final Map tags = acc.toSpanTags(); + assertTrue(tags.isEmpty()); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java new file mode 100644 index 00000000000..a82d7e301af --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentInterceptorTest.java @@ -0,0 +1,162 @@ +package com.datadog.featureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.trace.api.interceptor.MutableSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Write-tier interceptor suite: partial-flush correctness (the long-running-trace regression), + * identity keying, and the no-state fast path. dd-trace-core runs {@code onTraceComplete} on every + * flush; a partial flush excludes the still-open root, so the interceptor must only flush+remove + * when the local root is present in the fragment. + */ +class SpanEnrichmentInterceptorTest { + + /** A mock local-root span reporting itself as its own local root. */ + private static AgentSpan rootSpan() { + final AgentSpan root = mock(AgentSpan.class); + when(root.getLocalRootSpan()).thenReturn(root); + return root; + } + + /** A mock child span whose local root is {@code root} but which is itself NOT the root. */ + private static AgentSpan childOf(final AgentSpan root) { + final AgentSpan child = mock(AgentSpan.class); + when(child.getLocalRootSpan()).thenReturn(root); + return child; + } + + @Test + void priorityIsUnique() { + assertEquals(4, new SpanEnrichmentInterceptor(new SpanEnrichmentStates()).priority()); + } + + @Test + void emptyOrNoStateIsANoOpFastPath() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + // empty trace + assertTrue(interceptor.onTraceComplete(Collections.emptyList()).isEmpty()); + // no accumulated state => fast path, never touches the span + final AgentSpan root = rootSpan(); + interceptor.onTraceComplete(Collections.singletonList(root)); + verify(root, never()).setTag(anyString(), anyString()); + } + + @Test + void finalFlushWithRootPresentWritesTags() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(5); + + interceptor.onTraceComplete(Collections.singletonList(root)); + + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 + assertTrue(states.isEmpty(), "state must be removed on the final flush"); + } + + @Test + void partialFlushExcludingRootPreservesState() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(100); + states.getOrCreate(root).addSerialId(108); + + // PARTIAL flush: a fragment of children only — the open root is NOT in the collection. + final AgentSpan child1 = childOf(root); + final AgentSpan child2 = childOf(root); + interceptor.onTraceComplete(Arrays.asList(child1, child2)); + + verify(child1, never()).setTag(anyString(), anyString()); + verify(child2, never()).setTag(anyString(), anyString()); + verify(root, never()).setTag(anyString(), anyString()); + final SpanEnrichmentAccumulator surviving = states.peek(root); + assertNotNull(surviving, "partial flush must NOT remove the accumulator"); + assertTrue(surviving.serialIdsView().contains(100) && surviving.serialIdsView().contains(108)); + + // more evaluations, then the FINAL flush with the root present + states.getOrCreate(root).addSerialId(128); + states.getOrCreate(root).addSerialId(130); + final AgentSpan lateChild = childOf(root); + interceptor.onTraceComplete(Arrays.asList(lateChild, root)); + + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ZAgUAg=="); // {100,108,128,130} + assertTrue(states.isEmpty(), "state removed only on the final flush"); + } + + @Test + void childOnlyFragmentNeverTagsAChildSpan() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(7); + + final AgentSpan child = childOf(root); + interceptor.onTraceComplete(Collections.singletonList(child)); + + verify(child, never()).setTag(anyString(), anyString()); + verify(root, never()).setTag(anyString(), anyString()); + assertNotNull(states.peek(root), "child-only fragment must keep state"); + } + + @Test + void distinctTracesDoNotMerge() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan rootA = rootSpan(); + final AgentSpan rootB = rootSpan(); + states.getOrCreate(rootA).addSerialId(100); + states.getOrCreate(rootB).addSerialId(130); + assertEquals(2, states.size(), "distinct root spans must not share an accumulator"); + + interceptor.onTraceComplete(Collections.singletonList(rootA)); + verify(rootA).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ZA=="); // {100} -> 0x64 + interceptor.onTraceComplete(Collections.singletonList(rootB)); + verify(rootB).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "ggE="); // {130} -> 0x82 0x01 + } + + @Test + void neverCompletingTracesAreNotCapped() { + // No cap / eviction: while their local-root spans are reachable, every never-completing trace + // keeps its accumulator. Weak-key reclamation (not a fixed count) bounds memory. + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final int churn = 20_000; // well beyond the old 4096 cap + final List liveRoots = new ArrayList<>(churn); + for (int i = 0; i < churn; i++) { + final AgentSpan root = rootSpan(); + liveRoots.add(root); + states.getOrCreate(root).addSerialId(i % 1000 + 1); + } + assertEquals(churn, states.size(), "no cap/eviction: every live root keeps its accumulator"); + } + + @Test + void keyingSymmetryRootResolvedFromFragmentMatchesCapturedRoot() { + final SpanEnrichmentStates states = new SpanEnrichmentStates(); + final SpanEnrichmentInterceptor interceptor = new SpanEnrichmentInterceptor(states); + final AgentSpan root = rootSpan(); + states.getOrCreate(root).addSerialId(5); + + // The interceptor resolves the root from the fragment (root + a late child); it must be the + // same object so the remove hits the captured accumulator. + final AgentSpan child = childOf(root); + interceptor.onTraceComplete(Arrays.asList(child, root)); + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 + assertTrue(states.isEmpty()); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java new file mode 100644 index 00000000000..c45ff6b2e66 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/SpanEnrichmentWriterTest.java @@ -0,0 +1,136 @@ +package com.datadog.featureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.trace.api.featureflag.SpanEnrichmentEvent; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +/** + * Write-tier listener suite: the agent-side {@link SpanEnrichmentWriter} translates {@link + * SpanEnrichmentEvent}s from the flag-eval seam into per-local-root accumulator state (resolving + * the active root through an injectable resolver so no static tracer is needed). + */ +class SpanEnrichmentWriterTest { + + private static AgentSpan rootSpan() { + final AgentSpan root = mock(AgentSpan.class); + when(root.getLocalRootSpan()).thenReturn(root); + return root; + } + + private static SpanEnrichmentWriter writerFor(final AgentSpan root) { + return new SpanEnrichmentWriter(() -> root); + } + + @Test + void serialIdWithDoLogAndTargetingKeyRecordsSerialAndSubject() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(42, true, "user-1")); + + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertNotNull(state); + assertTrue(state.serialIdsView().contains(42)); + assertEquals(1, state.subjectCount(), "doLog=true + targeting key => subject recorded"); + } + + @Test + void serialIdWithoutDoLogRecordsNoSubject() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(7, false, "user-1")); + + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertTrue(state.serialIdsView().contains(7)); + assertEquals(0, state.subjectCount(), "doLog=false must not record a subject"); + } + + @Test + void serialIdWithNullTargetingKeyRecordsNoSubject() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(9, true, null)); + + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertTrue(state.serialIdsView().contains(9)); + assertEquals(0, state.subjectCount(), "no targeting key => no subject"); + } + + @Test + void runtimeDefaultRecordsDefault() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.runtimeDefault("flag", Collections.singletonMap("k", "v"))); + + final SpanEnrichmentAccumulator state = writer.states().peek(root); + assertNotNull(state); + assertEquals(1, state.defaultCount()); + } + + @Test + void noActiveSpanAccumulatesNothing() { + final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> null); + writer.accept(SpanEnrichmentEvent.serialId(1, true, "user-1")); + assertTrue(writer.states().isEmpty(), "no active span => no accumulator state"); + } + + @Test + void nullEventIsANoOp() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(null); + assertTrue(writer.states().isEmpty()); + } + + @Test + void endToEndAccumulateThenFlush() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + + writer.interceptor().onTraceComplete(Collections.singletonList(root)); + verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 + assertTrue(writer.states().isEmpty(), "flush removes the accumulated state"); + } + + @Test + void doesNotAccumulateWhenInterceptorCannotRegister() { + final AgentSpan root = rootSpan(); + // Registrar always rejects → nothing would ever flush the state, so we must not accumulate. + final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> root, interceptor -> false); + writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + assertTrue( + writer.states().isEmpty(), "no accumulation when the interceptor cannot be registered"); + } + + @Test + void agentSingletonIsStableAcrossRestarts() { + // FeatureFlaggingSystem reuses this one instance across start/stop, so the (unremovable) trace + // interceptor is registered exactly once and never re-registered on restart. + final SpanEnrichmentWriter first = SpanEnrichmentWriter.getInstance(); + final SpanEnrichmentWriter second = SpanEnrichmentWriter.getInstance(); + assertSame(first, second, "agent wiring must reuse one writer across restarts"); + assertSame(first.interceptor(), second.interceptor(), "same interceptor across restarts"); + assertSame(first.states(), second.states(), "same state store across restarts"); + } + + @Test + void distinctEventsUnderSameRootShareAccumulator() { + final AgentSpan root = rootSpan(); + final SpanEnrichmentWriter writer = writerFor(root); + writer.accept(SpanEnrichmentEvent.serialId(100, false, null)); + writer.accept(SpanEnrichmentEvent.serialId(108, false, null)); + assertEquals(1, writer.states().size(), "same root => one shared accumulator"); + verify(root, never()).setTag(anyString(), anyString()); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 36b1d9b95df..095376eaabc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -161,6 +161,7 @@ include( ":products:feature-flagging:feature-flagging-agent", ":products:feature-flagging:feature-flagging-api", ":products:feature-flagging:feature-flagging-bootstrap", + ":products:feature-flagging:feature-flagging-config", ":products:feature-flagging:feature-flagging-lib" )