From cd24d56ff30d040c5486ea4a2d801e3a257522e1 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 1 Oct 2026 14:50:36 +0200 Subject: [PATCH 01/50] WIP --- .gitlab-ci.yml | 8 +- .../trace/agent/tooling/AgentInstaller.java | 3 + .../agent/tooling/InstrumenterModule.java | 2 + .../dd-openfeature-connector/build.gradle | 38 + .../dd-openfeature-connector/gradle.lockfile | 130 ++ .../DDOpenFeatureInstrumentation.java | 53 + .../openfeature/AgentConfigurationSource.java | 19 + .../openfeature/AgentEventProxy.java | 22 + .../openfeature/AgentHealthMetrics.java | 19 + .../openfeature/AgentSpanEnricher.java | 25 + .../openfeature/DetectConnectorAdvice.java | 11 + .../openfeature/JavaAgentConnector.java | 63 + ...tureInstrumentationDisabledForkedTest.java | 26 + .../DDOpenFeatureInstrumentationTest.java | 103 ++ dd-openfeature/README.md | 78 + dd-openfeature/build.gradle.kts | 71 + dd-openfeature/gradle.lockfile | 81 + .../com/datadog/openfeature/DDEvaluator.java | 572 +++++++ .../com/datadog}/openfeature/Evaluator.java | 2 +- .../openfeature/FlagEvalLoggingHook.java | 43 +- .../datadog}/openfeature/FlagEvalMetrics.java | 76 +- .../openfeature/FlagEvalMetricsHook.java | 2 +- .../com/datadog}/openfeature/Provider.java | 78 +- .../openfeature/SpanEnrichmentHook.java | 26 +- .../internal/ConfigurationService.java | 18 + .../openfeature/internal/EventQueue.java | 107 ++ .../internal/FeatureFlagsRuntime.java | 227 +++ .../openfeature/internal/JsonWriting.java | 96 ++ .../openfeature/internal/RuntimeServices.java | 40 + .../internal/config/SdkVersion.java | 30 + .../openfeature/internal/config/Settings.java | 246 +++ .../connector/ConfigurationSource.java | 15 + .../internal/connector/Connector.java | 41 + .../internal/connector/Connectors.java | 16 + .../EventProxyUnavailableException.java | 13 + .../internal/connector/EventTransport.java | 16 + .../internal/connector/HealthMetrics.java | 19 + .../internal/connector/NoConnector.java | 33 + .../internal/connector/SpanEnricher.java | 34 + .../internal/connector/package-info.java | 8 + .../delivery/CdnConfigurationSource.java | 342 +++++ .../delivery/DirectIntakeTransport.java | 103 ++ .../internal/delivery/FallbackTransport.java | 39 + .../openfeature/internal/delivery/Gzip.java | 34 + .../internal/delivery/Headers.java | 28 + .../delivery/RemoteConfigurationService.java | 57 + .../evaluation/ConfigurationStore.java | 32 + .../evaluation/EvaluationContext.java | 10 + .../evaluation/EvaluationMetadata.java | 50 + .../internal/evaluation/EvaluationResult.java | 137 ++ .../internal/evaluation/FlagEvaluator.java | 547 +++++++ .../internal}/exposure/Allocation.java | 2 +- .../internal/exposure}/ExposureCache.java | 3 +- .../internal}/exposure/ExposureEvent.java | 12 +- .../internal/exposure/ExposurePipeline.java | 108 +- .../internal/exposure/ExposuresRequest.java | 53 + .../openfeature/internal}/exposure/Flag.java | 2 +- .../internal/exposure}/LRUExposureCache.java | 13 +- .../internal}/exposure/Subject.java | 2 +- .../internal}/exposure/Variant.java | 2 +- .../flagevaluation/FlagEvalEvent.java | 2 +- .../FlagEvaluationAggregator.java | 19 +- .../FlagEvaluationPayloads.java | 112 +- .../FlagEvaluationPipeline.java | 200 +-- .../openfeature/internal/ufc/Allocation.java | 28 + .../internal/ufc}/ConditionConfiguration.java | 4 +- .../internal/ufc}/ConditionOperator.java | 2 +- .../internal/ufc}/Environment.java | 4 +- .../openfeature/internal/ufc}/Flag.java | 4 +- .../internal/ufc}/ParsedSemver.java | 2 +- .../openfeature/internal/ufc}/Rule.java | 4 +- .../internal/ufc}/ServerConfiguration.java | 12 +- .../openfeature/internal/ufc/Shard.java | 16 + .../openfeature/internal/ufc/ShardRange.java | 12 + .../openfeature/internal/ufc}/Split.java | 9 +- .../ufc/UniversalFlagConfigParser.java | 568 +++++++ .../openfeature/internal/ufc}/ValueType.java | 2 +- .../openfeature/internal/ufc}/Variant.java | 4 +- .../trace/api/openfeature/Provider.java | 48 + .../openfeature/CanonicalFixtureTest.java | 205 +++ .../DDEvaluatorSpanEnrichmentTest.java | 67 + .../datadog}/openfeature/DDEvaluatorTest.java | 812 +++------- .../openfeature/FlagEvalLoggingHookTest.java | 116 +- .../openfeature/FlagEvalMetricsHookTest.java | 2 +- .../FlagEvalMetricsLateSdkForkedTest.java | 52 + .../openfeature/FlagEvalMetricsTest.java | 2 +- .../datadog}/openfeature/ProviderTest.java | 300 ++-- .../openfeature/SpanEnrichmentHookTest.java | 100 +- .../openfeature/internal/EventQueueTest.java | 129 ++ .../internal/FeatureFlagsRuntimeTest.java | 212 +++ .../openfeature/internal/JsonReading.java | 101 ++ .../openfeature/internal/LocalHttpServer.java | 163 ++ .../internal/config/SettingsTest.java | 168 ++ .../internal/config/TestSettings.java | 31 + .../delivery/CdnConfigurationSourceTest.java | 461 ++++++ .../delivery/DirectIntakeTransportTest.java | 120 ++ .../delivery/FallbackTransportTest.java | 52 + .../exposure/ExposurePipelineTest.java | 299 ++++ .../exposure/ExposuresRequestTest.java | 62 + .../exposure}/LRUExposureCacheTest.java | 13 +- .../FlagEvaluationAggregatorTest.java | 5 +- .../FlagEvaluationPayloadsTest.java | 29 +- .../FlagEvaluationPipelineTest.java | 484 ++++++ .../FlagEvaluationTestSupport.java | 226 +++ .../internal/ufc}/ParsedSemverTest.java | 2 +- .../UniversalFlagConfigParserJsonApiTest.java | 110 +- .../ufc/UniversalFlagConfigParserTest.java | 273 ++++ .../trace/api/openfeature/ProviderTest.java | 32 + .../resources}/allocation-window-dates.json | 0 .../src/test/resources}/empty-config.json | 0 .../resources}/malformed-allocations.json | 0 .../src/test/resources}/null-flag.json | 0 .../src/test/resources}/null-flags.json | 0 .../allocation-window-dates.json | 35 + .../resources/remote-config/empty-config.json | 8 + .../remote-config/malformed-allocations.json | 45 + .../resources/remote-config/null-flag.json | 3 +- .../resources/remote-config/null-flags.json | 8 + .../resources/remote-config/serial-id.json | 0 .../remote-config/sibling-serial-ids.json | 0 .../remote-config/unknown-operator.json | 0 .../unknown-top-level-fields.json | 0 .../src/test/resources/serial-id.json | 34 + .../test/resources/sibling-serial-ids.json | 59 + .../src/test/resources/unknown-operator.json | 67 + .../resources/unknown-top-level-fields.json | 13 + .../openfeature/application/build.gradle | 4 +- .../openfeature/OpenFeatureConfiguration.java | 2 +- dd-smoke-tests/openfeature/build.gradle | 3 +- dd-smoke-tests/openfeature/gradle.lockfile | 7 +- .../AbstractOpenFeatureSmokeTest.groovy | 213 +++ .../OpenFeatureAgentlessSmokeTest.groovy | 24 + .../OpenFeatureProviderSmokeTest.groovy | 185 +-- .../OpenFeatureStandaloneSmokeTest.groovy | 35 + .../datadog/trace/api/ConfigDefaults.java | 2 - gradle/libs.versions.toml | 1 + .../main/java/datadog/trace/api/Config.java | 55 - .../datadog/trace/api/InstrumenterConfig.java | 15 + .../java/datadog/trace/api/ConfigTest.java | 35 - metadata/agent-jar-checks.properties | 1 + .../feature-flagging-agent/gradle.lockfile | 6 +- .../featureflag/FeatureFlaggingSystem.java | 195 +-- .../FeatureFlaggingSystemTest.java | 393 +---- .../feature-flagging-api/README.md | 103 -- .../feature-flagging-api/build.gradle.kts | 90 -- .../feature-flagging-api/gradle.lockfile | 88 -- .../FlagEvalHookHotPathBenchmark.java | 203 --- .../trace/api/openfeature/DDEvaluator.java | 1040 ------------- .../api/openfeature/SpanEnrichmentGate.java | 25 - .../DDEvaluatorSpanEnrichmentForkedTest.java | 98 -- .../build.gradle.kts | 25 +- .../gradle.lockfile | 2 +- .../featureflag/FeatureFlaggingGateway.java | 201 ++- .../api/featureflag/SpanEnrichmentEvent.java | 82 - .../exposure/ExposuresRequest.java | 15 - .../flagevaluation/FlagEvaluationWriter.java | 46 - .../api/featureflag/ufc/v1/Allocation.java | 80 - .../trace/api/featureflag/ufc/v1/Feature.java | 19 - .../trace/api/featureflag/ufc/v1/Shard.java | 22 - .../api/featureflag/ufc/v1/ShardRange.java | 18 - .../FeatureFlaggingGatewayTest.java | 110 +- .../featureflag/SpanEnrichmentEventTest.java | 56 - .../flagevaluation/FlagEvalEventTest.java | 54 - .../feature-flagging-lib/build.gradle.kts | 25 +- .../feature-flagging-lib/gradle.lockfile | 6 +- ...gEvaluationEnqueueContentionBenchmark.java | 197 --- .../FlagEvaluationHotPathBenchmark.java | 168 -- .../AgentlessConfigurationSource.java | 489 ------ .../AgentlessFeatureFlagBackendApi.java | 101 -- .../ConfigurationSourceService.java | 10 - .../com/datadog/featureflag/EventProxy.java | 103 ++ .../datadog/featureflag/ExposureWriter.java | 14 - .../FeatureFlagBackendApiFactory.java | 100 -- .../featureflag/FeatureFlagEventType.java | 12 +- .../featureflag/FeatureFlagEvpContext.java | 22 - .../featureflag/FeatureFlagEvpPublisher.java | 71 - .../featureflag/FeatureFlagsBackend.java | 140 ++ .../featureflag/JsonApiUfcResponseParser.java | 92 -- .../featureflag/RemoteConfigServiceImpl.java | 84 +- .../featureflag/SpanEnrichmentWriter.java | 102 +- .../UniversalFlagConfigParser.java | 641 -------- .../AgentlessConfigurationSourceTest.java | 1363 ----------------- .../AgentlessFeatureFlagBackendApiTest.java | 196 --- .../datadog/featureflag/EventProxyTest.java | 102 ++ .../featureflag/ExposureWriterTests.java | 546 ------- .../FeatureFlagBackendApiFactoryTest.java | 158 -- .../FeatureFlagEvpPublisherTest.java | 128 -- .../featureflag/FeatureFlagsBackendTest.java | 113 ++ .../FlagEvaluationTestSupport.java | 246 --- .../FlagEvaluationWriterImplTest.java | 861 ----------- .../RemoteConfigServiceImplTest.java | 554 +------ .../featureflag/SpanEnrichmentWriterTest.java | 46 +- settings.gradle.kts | 3 +- 193 files changed, 9797 insertions(+), 10487 deletions(-) create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java create mode 100644 dd-openfeature/README.md create mode 100644 dd-openfeature/build.gradle.kts create mode 100644 dd-openfeature/gradle.lockfile create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java rename {products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api => dd-openfeature/src/main/java/com/datadog}/openfeature/Evaluator.java (91%) rename {products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api => dd-openfeature/src/main/java/com/datadog}/openfeature/FlagEvalLoggingHook.java (79%) rename {products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api => dd-openfeature/src/main/java/com/datadog}/openfeature/FlagEvalMetrics.java (58%) rename {products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api => dd-openfeature/src/main/java/com/datadog}/openfeature/FlagEvalMetricsHook.java (96%) rename {products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api => dd-openfeature/src/main/java/com/datadog}/openfeature/Provider.java (82%) rename {products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api => dd-openfeature/src/main/java/com/datadog}/openfeature/SpanEnrichmentHook.java (86%) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/JsonWriting.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Gzip.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal}/exposure/Allocation.java (70%) rename {products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure}/ExposureCache.java (94%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal}/exposure/ExposureEvent.java (70%) rename products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java => dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java (51%) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal}/exposure/Flag.java (68%) rename {products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure}/LRUExposureCache.java (70%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal}/exposure/Subject.java (83%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal}/exposure/Variant.java (69%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal}/flagevaluation/FlagEvalEvent.java (98%) rename {products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation}/FlagEvaluationAggregator.java (96%) rename {products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag => dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation}/FlagEvaluationPayloads.java (69%) rename products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java => dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java (74%) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/ConditionConfiguration.java (85%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/ConditionOperator.java (81%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/Environment.java (54%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/Flag.java (89%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/ParsedSemver.java (99%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/Rule.java (71%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/ServerConfiguration.java (63%) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/Split.java (75%) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/ValueType.java (62%) rename {products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc}/Variant.java (68%) create mode 100644 dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java rename {products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api => dd-openfeature/src/test/java/com/datadog}/openfeature/DDEvaluatorTest.java (58%) rename {products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api => dd-openfeature/src/test/java/com/datadog}/openfeature/FlagEvalLoggingHookTest.java (87%) rename {products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api => dd-openfeature/src/test/java/com/datadog}/openfeature/FlagEvalMetricsHookTest.java (99%) create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java rename {products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api => dd-openfeature/src/test/java/com/datadog}/openfeature/FlagEvalMetricsTest.java (99%) rename {products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api => dd-openfeature/src/test/java/com/datadog}/openfeature/ProviderTest.java (65%) rename {products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api => dd-openfeature/src/test/java/com/datadog}/openfeature/SpanEnrichmentHookTest.java (77%) create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/JsonReading.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/LocalHttpServer.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java rename {products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag => dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure}/LRUExposureCacheTest.java (96%) rename {products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag => dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation}/FlagEvaluationAggregatorTest.java (98%) rename {products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag => dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation}/FlagEvaluationPayloadsTest.java (94%) create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java rename {products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1 => dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc}/ParsedSemverTest.java (99%) rename products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java => dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java (88%) create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java create mode 100644 dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java rename {products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config => dd-openfeature/src/test/resources}/allocation-window-dates.json (100%) rename {products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config => dd-openfeature/src/test/resources}/empty-config.json (100%) rename {products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config => dd-openfeature/src/test/resources}/malformed-allocations.json (100%) rename {products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config => dd-openfeature/src/test/resources}/null-flag.json (100%) rename {products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config => dd-openfeature/src/test/resources}/null-flags.json (100%) create mode 100644 dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json create mode 100644 dd-openfeature/src/test/resources/remote-config/empty-config.json create mode 100644 dd-openfeature/src/test/resources/remote-config/malformed-allocations.json rename products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json => dd-openfeature/src/test/resources/remote-config/null-flag.json (89%) create mode 100644 dd-openfeature/src/test/resources/remote-config/null-flags.json rename {products/feature-flagging/feature-flagging-lib => dd-openfeature}/src/test/resources/remote-config/serial-id.json (100%) rename {products/feature-flagging/feature-flagging-lib => dd-openfeature}/src/test/resources/remote-config/sibling-serial-ids.json (100%) rename {products/feature-flagging/feature-flagging-lib => dd-openfeature}/src/test/resources/remote-config/unknown-operator.json (100%) rename {products/feature-flagging/feature-flagging-lib => dd-openfeature}/src/test/resources/remote-config/unknown-top-level-fields.json (100%) create mode 100644 dd-openfeature/src/test/resources/serial-id.json create mode 100644 dd-openfeature/src/test/resources/sibling-serial-ids.json create mode 100644 dd-openfeature/src/test/resources/unknown-operator.json create mode 100644 dd-openfeature/src/test/resources/unknown-top-level-fields.json create mode 100644 dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy create mode 100644 dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy create mode 100644 dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy delete mode 100644 products/feature-flagging/feature-flagging-api/README.md delete mode 100644 products/feature-flagging/feature-flagging-api/build.gradle.kts delete mode 100644 products/feature-flagging/feature-flagging-api/gradle.lockfile delete mode 100644 products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Feature.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java delete mode 100644 products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/EventProxyTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ba77c823f7a..b26bb177572 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -355,7 +355,7 @@ build: script: - if [ $CI_PIPELINE_SOURCE == "schedule" ] ; then ./gradlew resolveAndLockAll --write-locks $GRADLE_ARGS; fi - ./gradlew --version - - ./gradlew clean :dd-java-agent:shadowJar :dd-java-agent:check :dd-trace-api:jar :dd-trace-ot:shadowJar :products:feature-flagging:feature-flagging-api:jar -PskipTests -x spotlessCheck $GRADLE_ARGS + - ./gradlew clean :dd-java-agent:shadowJar :dd-java-agent:check :dd-trace-api:jar :dd-trace-ot:shadowJar :dd-openfeature:jar -PskipTests -x spotlessCheck $GRADLE_ARGS - echo UPSTREAM_TRACER_VERSION=$(java -jar workspace/dd-java-agent/build/libs/*.jar) >> upstream.env - echo "BUILD_JOB_NAME=$CI_JOB_NAME" >> build.env - echo "BUILD_JOB_ID=$CI_JOB_ID" >> build.env @@ -365,7 +365,7 @@ build: - 'workspace/dd-java-agent/build/libs/*.jar' - 'workspace/dd-trace-api/build/libs/*.jar' - 'workspace/dd-trace-ot/build/libs/*.jar' - - 'workspace/products/feature-flagging/feature-flagging-api/build/libs/*.jar' + - 'workspace/dd-openfeature/build/libs/*.jar' - 'upstream.env' - '.gradle/daemon/*/*.out.log' reports: @@ -482,11 +482,11 @@ publish-artifacts-to-s3: - aws s3 cp workspace/dd-java-agent/build/libs/dd-java-agent-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-java-agent.jar - aws s3 cp workspace/dd-trace-api/build/libs/dd-trace-api-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-trace-api.jar - aws s3 cp workspace/dd-trace-ot/build/libs/dd-trace-ot-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-trace-ot.jar - - aws s3 cp workspace/products/feature-flagging/feature-flagging-api/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-openfeature.jar + - aws s3 cp workspace/dd-openfeature/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-openfeature.jar - aws s3 cp workspace/dd-java-agent/build/libs/dd-java-agent-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-java-agent.jar - aws s3 cp workspace/dd-trace-api/build/libs/dd-trace-api-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-trace-api.jar - aws s3 cp workspace/dd-trace-ot/build/libs/dd-trace-ot-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-trace-ot.jar - - aws s3 cp workspace/products/feature-flagging/feature-flagging-api/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-openfeature.jar + - aws s3 cp workspace/dd-openfeature/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-openfeature.jar - | cat << EOF > links.json { diff --git a/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java b/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java index 1dae022b06c..63068a7ceb0 100644 --- a/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java +++ b/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java @@ -347,6 +347,9 @@ public static Set getEnabledSystems() { if (cfg.isLlmObsEnabled()) { enabledSystems.add(InstrumenterModule.TargetSystem.LLMOBS); } + if (cfg.isFeatureFlaggingEnabled()) { + enabledSystems.add(InstrumenterModule.TargetSystem.FEATURE_FLAGS); + } return enabledSystems; } diff --git a/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java b/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java index 6baeca12b62..ab2c4d78637 100644 --- a/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java +++ b/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java @@ -44,6 +44,7 @@ public abstract class InstrumenterModule implements Instrumenter { *
  • {@link TargetSystem#USM usm} *
  • {@link TargetSystem#CONTEXT_TRACKING context-tracking} *
  • {@link TargetSystem#RASP rasp} + *
  • {@link TargetSystem#FEATURE_FLAGS feature-flags} * */ public enum TargetSystem { @@ -56,6 +57,7 @@ public enum TargetSystem { LLMOBS, CONTEXT_TRACKING, RASP, + FEATURE_FLAGS, } private static final Logger log = LoggerFactory.getLogger(InstrumenterModule.class); diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle new file mode 100644 index 00000000000..4ed6ed0983d --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'dd-trace-java.module.instrumentation' + id 'idea' +} + +// TODO Add a muzzle pass directive on com.datadoghq:dd-openfeature once the SDK is published. + +tracerJava { + addSourceSetFor(JavaVersion.VERSION_11) +} + +testJvmConstraints { + minJavaVersion = JavaVersion.VERSION_11 +} + +dependencies { + main_java11CompileOnly project(':dd-openfeature') + main_java11CompileOnly project(':products:feature-flagging:feature-flagging-bootstrap') + main_java11CompileOnly libs.spotbugs.annotations + + testImplementation project(':dd-openfeature') + testImplementation project(':products:feature-flagging:feature-flagging-bootstrap') + testImplementation libs.bundles.mockito +} + +tasks.named("compileMain_java11Java", JavaCompile) { + configureCompiler(it, JavaVersion.VERSION_11) +} + +tasks.named("compileTestJava", JavaCompile) { + configureCompiler(it, JavaVersion.VERSION_11) +} + +idea { + module { + jdkName = '11' + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile new file mode 100644 index 00000000000..5de2fc3c490 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile @@ -0,0 +1,130 @@ +# 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 :dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector:dependencies --write-locks +cafe.cryptography:curve25519-elisabeth:0.1.0=testRuntimeClasspath +cafe.cryptography:ed25519-elisabeth:0.1.0=testRuntimeClasspath +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.blogspot.mydailyjava:weak-lock-free:0.17=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq.okhttp3:okhttp:3.12.15=testCompileClasspath,testRuntimeClasspath +com.datadoghq.okio:okio:1.17.6=testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-instrument-java:0.0.5=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:dd-javac-plugin-client:0.2.2=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.datadoghq:java-dogstatsd-client:4.4.5=testRuntimeClasspath +com.datadoghq:sketches-java:0.8.3=testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.jnr:jffi:1.3.15=testRuntimeClasspath +com.github.jnr:jnr-a64asm:1.0.0=testRuntimeClasspath +com.github.jnr:jnr-constants:0.10.4=testRuntimeClasspath +com.github.jnr:jnr-enxio:0.32.20=testRuntimeClasspath +com.github.jnr:jnr-ffi:2.2.19=testRuntimeClasspath +com.github.jnr:jnr-posix:3.1.22=testRuntimeClasspath +com.github.jnr:jnr-unixsocket:0.38.25=testRuntimeClasspath +com.github.jnr:jnr-x86asm:1.0.2=testRuntimeClasspath +com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,main_java11CompileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.10.4=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.auto.service:auto-service-annotations:1.1.1=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath +com.google.auto.service:auto-service:1.1.1=annotationProcessor,testAnnotationProcessor +com.google.auto:auto-common:1.2.1=annotationProcessor,testAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,compileClasspath,main_java11CompileClasspath,spotbugs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.14.0=spotbugs +com.google.errorprone:error_prone_annotations:2.18.0=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.47.0=testCompileClasspath,testRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.48.0=spotbugs +com.google.guava:failureaccess:1.0.1=annotationProcessor,testAnnotationProcessor +com.google.guava:failureaccess:1.0.3=testCompileClasspath,testRuntimeClasspath +com.google.guava:guava:32.0.1-jre=annotationProcessor,testAnnotationProcessor +com.google.guava:guava:33.6.0-jre=testCompileClasspath,testRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +com.google.j2objc:j2objc-annotations:2.8=annotationProcessor,testAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=testCompileClasspath,testRuntimeClasspath +com.google.re2j:re2j:1.8=testRuntimeClasspath +com.squareup.moshi:moshi:1.11.0=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:logging-interceptor:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okhttp3:okhttp:3.12.12=testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=testCompileClasspath,testRuntimeClasspath +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath +commons-io:commons-io:2.21.0=spotbugs +de.thetaphi:forbiddenapis:3.11=compileClasspath,testCompileClasspath,testRuntimeClasspath +dev.openfeature:sdk:1.22.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.66.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.66.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.66.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +io.sqreen:libsqreen:17.5.0=testRuntimeClasspath +javax.servlet:javax.servlet-api:3.1.0=testCompileClasspath,testRuntimeClasspath +jaxen:jaxen:2.0.6=spotbugs +junit:junit:4.13.2=testRuntimeClasspath +net.bytebuddy:byte-buddy-agent:1.18.12=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.12=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna-platform:5.8.0=testRuntimeClasspath +net.java.dev.jna:jna:5.8.0=testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.10=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.logging.log4j:log4j-api:2.26.1=spotbugs +org.apache.logging.log4j:log4j-core:2.26.1=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.33.0=annotationProcessor,testAnnotationProcessor +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-core:1.3=testRuntimeClasspath +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath +org.jspecify:jspecify:1.0.0=testCompileClasspath,testRuntimeClasspath +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.platform:junit-platform-runner:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-api:1.14.1=testRuntimeClasspath +org.junit.platform:junit-platform-suite-commons:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:4.4.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-analysis:9.7.1=testRuntimeClasspath +org.ow2.asm:asm-commons:9.10.1=spotbugs,testRuntimeClasspath +org.ow2.asm:asm-tree:9.10.1=spotbugs,testRuntimeClasspath +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm-util:9.7.1=testRuntimeClasspath +org.ow2.asm:asm:9.10.1=buildTimeInstrumentationPlugin,compileClasspath,main_java11CompileClasspath,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +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.30=buildTimeInstrumentationPlugin,compileClasspath,main_java11RuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=main_java11CompileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.snakeyaml:snakeyaml-engine:2.9=buildTimeInstrumentationPlugin,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath +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.2=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=main_java11AnnotationProcessor,spotbugsPlugins diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java new file mode 100644 index 00000000000..6722e067322 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java @@ -0,0 +1,53 @@ +package datadog.trace.instrumentation.openfeature; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.isStatic; +import static net.bytebuddy.matcher.ElementMatchers.takesNoArguments; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import java.util.Set; + +/** + * Connects the {@code dd-openfeature} SDK to the agent: Remote Configuration, the event platform + * proxy, health metrics and span enrichment. + */ +@AutoService(InstrumenterModule.class) +public class DDOpenFeatureInstrumentation extends InstrumenterModule + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public DDOpenFeatureInstrumentation() { + super("openfeature"); + } + + @Override + public boolean isApplicable(final Set enabledSystems) { + return enabledSystems.contains(TargetSystem.FEATURE_FLAGS); + } + + @Override + public String instrumentedType() { + return "com.datadog.openfeature.internal.connector.Connectors"; + } + + @Override + public String[] helperClassNames() { + // Helpers are compiled in the java11 source set, out of reach of helper discovery. + return new String[] { + packageName + ".AgentConfigurationSource", + packageName + ".AgentEventProxy", + packageName + ".AgentHealthMetrics", + packageName + ".AgentSpanEnricher", + packageName + ".JavaAgentConnector", + }; + } + + @Override + public void methodAdvice(final MethodTransformer transformer) { + transformer.applyAdvice( + isMethod().and(isStatic()).and(named("detect")).and(takesNoArguments()), + packageName + ".DetectConnectorAdvice"); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java new file mode 100644 index 00000000000..d25d4475212 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java @@ -0,0 +1,19 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import java.util.function.Consumer; + +/** Receives the flag configuration from the agent Remote Configuration client. */ +public final class AgentConfigurationSource implements ConfigurationSource { + private final FeatureFlaggingGateway.Backend backend; + + AgentConfigurationSource(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + } + + @Override + public AutoCloseable subscribe(final Consumer listener) { + return this.backend.subscribeRemoteConfig(listener); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java new file mode 100644 index 00000000000..5c30077065d --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java @@ -0,0 +1,22 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; +import com.datadog.openfeature.internal.connector.EventTransport; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import java.io.IOException; + +/** Posts events through the Datadog Agent event platform proxy. */ +public final class AgentEventProxy implements EventTransport { + private final FeatureFlaggingGateway.Backend backend; + + AgentEventProxy(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + } + + @Override + public void post(final String route, final byte[] json) throws IOException { + if (!this.backend.postEvent(route, json)) { + throw new EventProxyUnavailableException("Datadog Agent event platform proxy unavailable"); + } + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java new file mode 100644 index 00000000000..abee16aeae1 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java @@ -0,0 +1,19 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.HealthMetrics; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import javax.annotation.Nullable; + +/** Reports the SDK health counters through the agent telemetry. */ +public final class AgentHealthMetrics implements HealthMetrics { + private final FeatureFlaggingGateway.Backend backend; + + AgentHealthMetrics(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + } + + @Override + public void count(final String metric, final long value, @Nullable final String reason) { + this.backend.count(metric, value, reason); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java new file mode 100644 index 00000000000..5e0f87715cf --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java @@ -0,0 +1,25 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.SpanEnricher; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import javax.annotation.Nullable; + +/** Reflects flag evaluations onto the agent local root span. */ +public final class AgentSpanEnricher implements SpanEnricher { + private final FeatureFlaggingGateway.Backend backend; + + AgentSpanEnricher(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + } + + @Override + public void serialId( + final int serialId, final boolean doLog, @Nullable final String targetingKey) { + this.backend.enrichSerialId(serialId, doLog, targetingKey); + } + + @Override + public void runtimeDefault(final String flagKey, @Nullable final Object defaultValue) { + this.backend.enrichRuntimeDefault(flagKey, defaultValue); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java new file mode 100644 index 00000000000..deb9d2d7bbe --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java @@ -0,0 +1,11 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.Connector; +import net.bytebuddy.asm.Advice; + +public class DetectConnectorAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void detect(@Advice.Return(readOnly = false) Connector connector) { + connector = JavaAgentConnector.connect(connector); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java new file mode 100644 index 00000000000..f4d463dab3a --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java @@ -0,0 +1,63 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import javax.annotation.Nullable; + +/** The connector backed by the agent Feature Flags backend. */ +public final class JavaAgentConnector implements Connector { + private final FeatureFlaggingGateway.Backend backend; + private final HealthMetrics healthMetrics; + private final SpanEnricher spanEnricher; + + JavaAgentConnector(final FeatureFlaggingGateway.Backend backend) { + this.backend = backend; + this.healthMetrics = new AgentHealthMetrics(backend); + this.spanEnricher = new AgentSpanEnricher(backend); + } + + /** + * Connects the SDK to the agent backend. + * + * @param fallback the connector to use if the agent backend is not started. + * @return the agent connector, or the fallback one. + */ + public static Connector connect(final Connector fallback) { + final FeatureFlaggingGateway.Backend backend = FeatureFlaggingGateway.backend(); + return backend == null ? fallback : new JavaAgentConnector(backend); + } + + @Nullable + @Override + public String setting(final String key) { + return this.backend.setting(key); + } + + @Nullable + @Override + public ConfigurationSource remoteConfiguration() { + return this.backend.isRemoteConfigAvailable() + ? new AgentConfigurationSource(this.backend) + : null; + } + + @Nullable + @Override + public EventTransport eventProxy() { + return this.backend.isEventProxyAvailable() ? new AgentEventProxy(this.backend) : null; + } + + @Override + public HealthMetrics healthMetrics() { + return this.healthMetrics; + } + + @Override + public SpanEnricher spanEnricher() { + return this.spanEnricher; + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java new file mode 100644 index 00000000000..0c7dab1cfb8 --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java @@ -0,0 +1,26 @@ +package datadog.trace.instrumentation.openfeature; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.Connectors; +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.test.junit.utils.config.WithConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +@WithConfig(key = "feature.flags.enabled", value = "false") +class DDOpenFeatureInstrumentationDisabledForkedTest extends AbstractInstrumentationTest { + @AfterEach + void unregisterBackend() { + FeatureFlaggingGateway.register(null); + } + + @Test + void doesNotInstrumentWhenFeatureFlagsIsDisabled() { + FeatureFlaggingGateway.register(mock(FeatureFlaggingGateway.Backend.class)); + assertSame(Connector.NONE, Connectors.detect()); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java new file mode 100644 index 00000000000..10c46a4e4ac --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java @@ -0,0 +1,103 @@ +package datadog.trace.instrumentation.openfeature; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.Connectors; +import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; +import com.datadog.openfeature.internal.connector.EventTransport; +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +// Bootstrap types are not allowed in test class signatures, so the backend is a local variable. +class DDOpenFeatureInstrumentationTest extends AbstractInstrumentationTest { + @AfterEach + void unregisterBackend() { + FeatureFlaggingGateway.register(null); + } + + @Test + void detectsTheAgentConnector() { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + assertInstanceOf(JavaAgentConnector.class, Connectors.detect()); + } + + @Test + void keepsTheDefaultConnectorWithoutBackend() { + assertSame(Connector.NONE, Connectors.detect()); + } + + @Test + void delegatesSettingsAndHealthMetrics() { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + when(backend.setting("site")).thenReturn("datadoghq.eu"); + final Connector connector = Connectors.detect(); + + assertEquals("datadoghq.eu", connector.setting("site")); + connector.healthMetrics().count("metric", 2, "reason"); + verify(backend).count("metric", 2, "reason"); + } + + @Test + void exposesRemoteConfigurationOnlyWhenAvailable() throws Exception { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + final Connector connector = Connectors.detect(); + assertNull(connector.remoteConfiguration()); + + when(backend.isRemoteConfigAvailable()).thenReturn(true); + final AutoCloseable subscription = mock(AutoCloseable.class); + when(backend.subscribeRemoteConfig(any())).thenReturn(subscription); + final ConfigurationSource source = connector.remoteConfiguration(); + final Consumer listener = content -> {}; + + assertSame(subscription, source.subscribe(listener)); + verify(backend).subscribeRemoteConfig(listener); + } + + @Test + void postsThroughTheEventProxyOnlyWhenAvailable() throws Exception { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + final Connector connector = Connectors.detect(); + assertNull(connector.eventProxy()); + + when(backend.isEventProxyAvailable()).thenReturn(true); + final EventTransport proxy = connector.eventProxy(); + final byte[] payload = "{}".getBytes(UTF_8); + when(backend.postEvent("exposures", payload)).thenReturn(true); + proxy.post("exposures", payload); + verify(backend).postEvent("exposures", payload); + + when(backend.postEvent("exposures", payload)).thenReturn(false); + assertThrows(EventProxyUnavailableException.class, () -> proxy.post("exposures", payload)); + } + + @Test + void delegatesSpanEnrichment() { + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + final Connector connector = Connectors.detect(); + + connector.spanEnricher().serialId(42, true, "user"); + connector.spanEnricher().runtimeDefault("flag", "default"); + + verify(backend).enrichSerialId(42, true, "user"); + verify(backend).enrichRuntimeDefault("flag", "default"); + } +} diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md new file mode 100644 index 00000000000..26f604ba2e0 --- /dev/null +++ b/dd-openfeature/README.md @@ -0,0 +1,78 @@ +# Datadog OpenFeature provider + +`com.datadoghq:dd-openfeature` is the Datadog Feature Flags SDK for the [OpenFeature](https://openfeature.dev/) Java API. +It evaluates flags locally from the Universal Flag Configuration (UFC) and reports exposures and evaluation counts to Datadog. + +The SDK works on its own. When the Datadog Java agent (`dd-java-agent`) is attached, the SDK also connects to it to use Remote Configuration, the Datadog Agent event proxy, agent telemetry, and APM span enrichment. + +## Requirements + +* Java 11 or later +* A Datadog [API key](https://docs.datadoghq.com/account_management/api-app-keys/#api-keys) and [site](https://docs.datadoghq.com/getting_started/site/), unless events go through a Datadog Agent +* Optionally, `dd-java-agent` 1.67.0 or later + +## Setup + +Add the dependency. It brings the OpenFeature SDK, the OpenTelemetry API and `jackson-core`: + +```xml + + com.datadoghq + dd-openfeature + ${dd-openfeature.version} + +``` + +Register the provider: + +```java +import com.datadog.openfeature.Provider; +import dev.openfeature.sdk.OpenFeatureAPI; + +OpenFeatureAPI.getInstance().setProviderAndWait(new Provider()); +Client client = OpenFeatureAPI.getInstance().getClient(); +``` + +`new Provider(new Provider.Options().initTimeout(10, SECONDS))` changes how long initialization waits for the first flag configuration (30 seconds by default). + +## Configuration + +Settings are read from the Datadog Java agent configuration when attached, then from `dd.`-prefixed system properties, then from environment variables. + +| Environment variable | Default | Description | +|---|---|---| +| `DD_FEATURE_FLAGS_ENABLED` | `true` | Enables Feature Flags. `false` makes the provider fail initialization, so evaluations return defaults. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE` | `agentless` | `agentless` polls the configuration from the Datadog CDN. `remote_config` receives it through Remote Configuration and requires `dd-java-agent`. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL` | | Custom configuration endpoint. A URL without a path gets the default path. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS` | `30` | CDN poll interval. | +| `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS` | `5` | CDN request timeout. | +| `DD_FLAGGING_EVALUATION_COUNTS_ENABLED` | `true` | Sends aggregated flag evaluation counts. | +| `DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED` | `false` | Adds flag evaluations to the local root span. Requires `dd-java-agent`. | +| `DD_API_KEY`, `DD_SITE`, `DD_ENV`, `DD_SERVICE`, `DD_VERSION` | | Credentials and event context. | +| `DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED` | | Deprecated: `true` selects `remote_config`, `false` disables Feature Flags. An explicit source wins. | + +## Features by setup + +| Feature | SDK only | SDK + `dd-java-agent` | +|---|---|---| +| Configuration, `agentless` source | CDN | CDN | +| Configuration, `remote_config` source | Not supported | Remote Configuration | +| Exposures and evaluation counts | Direct intake, requires `DD_API_KEY` | Datadog Agent event proxy, with direct intake fallback in `agentless` mode | +| `feature_flag.evaluations` OpenTelemetry metric | Yes | Yes | +| Health metrics | No | Agent telemetry | +| Span enrichment | No | Yes | + +## Migrating from earlier versions + +* The provider moved to `com.datadog.openfeature.Provider`. `datadog.trace.api.openfeature.Provider` is kept as a deprecated alias. +* The SDK no longer requires `dd-java-agent` and no longer links against agent classes. +* `dd-openfeature` 1.66.0 and earlier require the agent classes this release removes. Upgrade `dd-openfeature` together with `dd-java-agent` 1.67.0 or later. + +## Design notes for contributors + +The SDK does not use any dd-trace-java internal module. Its parts that the agent can provide are behind the `com.datadog.openfeature.internal.connector` SPI: + +* `Connectors.detect()` returns `Connector.NONE` without the agent. +* The `:dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector` instrumentation replaces its result with a connector delegating to the agent backend (`products/feature-flagging/feature-flagging-lib`) through the bootstrap `FeatureFlaggingGateway`. +* The SPI is the only contract crossing SDK and agent versions. It must evolve in a backward compatible way, and the instrumentation references to it are checked by muzzle. +* UFC parsing, evaluation, event payloads and batching stay in the SDK. The agent forwards raw Remote Configuration documents and event payloads. diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts new file mode 100644 index 00000000000..5fd05d67d73 --- /dev/null +++ b/dd-openfeature/build.gradle.kts @@ -0,0 +1,71 @@ +import datadog.gradle.configureCompiler +import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension + +plugins { + `java-library` + idea + id("dd-trace-java.module.distributable.api") + id("dd-trace-java.version-file") +} + +description = "Datadog OpenFeature provider SDK." + +// Set both JAR and Maven artifact name +val openFeatureArtifactId = "dd-openfeature" +base { + archivesName.set(openFeatureArtifactId) +} + +publishing { + publications.withType().configureEach { + artifactId = openFeatureArtifactId + } +} + +configure { + minJavaVersion.set(JavaVersion.VERSION_11) +} + +idea { + module { + jdkName = "11" + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +// TODO Port the evaluation hot path JMH benchmarks removed with the former feature-flagging-api and +// feature-flagging-lib modules (FlagEvalHookHotPathBenchmark, FlagEvaluationHotPathBenchmark and +// FlagEvaluationEnqueueContentionBenchmark). +dependencies { + api("dev.openfeature:sdk:1.22.0") + api("io.opentelemetry:opentelemetry-api:1.66.0") + api(libs.jackson.core) + + testImplementation(libs.bundles.junit5) + testImplementation(libs.bundles.mockito) +} + +tasks.withType().configureEach { + configureCompiler(JavaVersion.VERSION_11) +} + +tasks.withType().configureEach { + javadocTool = javaToolchains.javadocToolFor(java.toolchain) +} + +tasks.named("jar") { + manifest { + attributes("Automatic-Module-Name" to "com.datadog.openfeature") + } +} + +// The dd-openfeature provider jar is not produced by the CI `build` job, so there is no reference +// artifact to compare against. Disable the release jar comparison gate registered by publish.gradle. +tasks.named("compareToReferenceJar") { + enabled = false +} diff --git a/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile new file mode 100644 index 00000000000..55a26499a91 --- /dev/null +++ b/dd-openfeature/gradle.lockfile @@ -0,0 +1,81 @@ +# 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 :dd-openfeature:dependencies --write-locks +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.10.4=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.14.0=spotbugs +com.google.errorprone:error_prone_annotations:2.48.0=spotbugs +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-io:commons-io:2.21.0=spotbugs +de.thetaphi:forbiddenapis:3.11=compileClasspath +dev.openfeature:sdk:1.22.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +io.opentelemetry:opentelemetry-api:1.66.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-common:1.66.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.opentelemetry:opentelemetry-context:1.66.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy-agent:1.18.12=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.12=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.10=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.logging.log4j:log4j-api:2.26.1=spotbugs +org.apache.logging.log4j:log4j-core:2.26.1=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.15=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt +org.jacoco:org.jacoco.core:0.8.15=jacocoAnt +org.jacoco:org.jacoco.report:0.8.15=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.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:4.4.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm-tree:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=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:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +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.2=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java new file mode 100644 index 00000000000..08af9e7e571 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -0,0 +1,572 @@ +package com.datadog.openfeature; + +import static java.util.Arrays.asList; + +import com.datadog.openfeature.internal.FeatureFlagsRuntime; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.evaluation.ConfigurationStore; +import com.datadog.openfeature.internal.evaluation.EvaluationResult; +import com.datadog.openfeature.internal.evaluation.FlagEvaluator; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.exposure.Subject; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.ValueType; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.time.Instant; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +class DDEvaluator implements Evaluator, Consumer { + + private static final Set> SUPPORTED_RESOLUTION_TYPES = + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); + + /** + * Maximum evaluation-context nesting depth captured on the hot path. Recursion runs on the + * caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep + * list/structure would overflow that thread's stack - and a StackOverflowError is not caught by + * the LinkageError | Exception guards that keep telemetry from breaking an evaluation. Values + * below the limit are truncated to null, the same way the cycle guard truncates. Kept aligned + * with the cross-SDK RFC target (4). + */ + static final int MAX_SNAPSHOT_DEPTH = 4; + + /** + * Maximum number of top-level context fields retained by copyPrunedContext. Bounds the width of + * the caller-supplied context and, transitively, the size of every FlagEvalEvent sitting in the + * async hand-off queue. Kept aligned with the cross-SDK RFC. + */ + static final int MAX_CONTEXT_FIELDS = 256; + + /** + * Maximum character length for a single context KEY retained by copyPrunedContext. Keys are + * stored verbatim in every full-tier bucket, so an unbounded key size would let a single caller + * inflate steady-state heap use. Longer keys cause the field to be skipped. + */ + static final int MAX_KEY_LENGTH = 256; + + /** + * Maximum character length for a single context string VALUE retained by copyPrunedContext. + * Longer values cause the field to be skipped (matches previous pruneContext behavior). + * Non-string scalars are not length-bounded. + */ + static final int MAX_VALUE_LENGTH = 256; + + /** + * Maximum number of elements walked per list encountered during copyPrunedContext. Bounds the + * fan-out of a single wide list at capture time so one caller cannot inflate the hot path with a + * huge but shallow structure. Elements past the limit are skipped. + */ + static final int MAX_LIST_ELEMENTS = 256; + + /** + * Maximum number of properties walked per structure encountered during copyPrunedContext. Same + * intent as MAX_LIST_ELEMENTS for structures. Properties past the limit are skipped. + */ + static final int MAX_STRUCTURE_PROPERTIES = 256; + + // 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"; + + // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing + // key = non-DD provider; the hook falls back to false (fail-closed). + static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; + + private final Runnable configCallback; + private final Connector connector; + private final Settings settings; + private final FlagEvaluator core; + private final ConfigurationStore configuration = new ConfigurationStore(); + private volatile FeatureFlagsRuntime runtime; + + DDEvaluator(final Runnable configCallback, final Connector connector, final Settings settings) { + this.configCallback = configCallback; + this.connector = connector; + this.settings = settings; + // When span enrichment is off, the __dd_* span-enrichment metadata is not attached to + // evaluations, so an enabled provider pays nothing extra. + this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled()); + } + + @Override + public synchronized boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { + if (this.runtime != null) { + return hasConfiguration(); + } + final FeatureFlagsRuntime acquired = FeatureFlagsRuntime.acquire(this.connector, this.settings); + this.runtime = acquired; + acquired.addConfigurationListener(this); + return this.configuration.await(timeout, unit) || hasConfiguration(); + } + + @Override + public boolean hasConfiguration() { + return this.configuration.get() != null; + } + + @Override + public synchronized void shutdown() { + final FeatureFlagsRuntime released = this.runtime; + this.runtime = null; + if (released != null) { + released.removeConfigurationListener(this); + FeatureFlagsRuntime.release(); + } + } + + /** + * @return the runtime, or {@code null} if the evaluator is not initialized. + */ + FeatureFlagsRuntime runtime() { + return this.runtime; + } + + @Override + public void accept(final ServerConfiguration config) { + this.configuration.set(config); + if (config != null) { + this.configuration.markReady(); + this.configCallback.run(); + } else if (this.configuration.wasReady()) { + this.configCallback.run(); + } + } + + @Override + @SuppressWarnings("unchecked") + public ProviderEvaluation evaluate( + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + final com.datadog.openfeature.internal.evaluation.EvaluationContext adapted = + context == null + ? null + : new com.datadog.openfeature.internal.evaluation.EvaluationContext() { + @Override + public String getTargetingKey() { + return context.getTargetingKey(); + } + + @Override + public boolean hasAttribute(final String name) { + return context.keySet().contains(name); + } + + @Override + public Object attribute(final String name) { + return context.convertValue(context.getValue(name)); + } + }; + final EvaluationResult result = + core.evaluate(configuration.get(), (Class) coreType(target), key, defaultValue, adapted); + final ImmutableMetadata.ImmutableMetadataBuilder metadata = ImmutableMetadata.builder(); + for (final Map.Entry entry : result.getFlagMetadata().values().entrySet()) { + final Object value = entry.getValue(); + if (value instanceof Boolean) metadata.addBoolean(entry.getKey(), (Boolean) value); + else if (value instanceof Integer) metadata.addInteger(entry.getKey(), (Integer) value); + else if (value instanceof Long) metadata.addLong(entry.getKey(), (Long) value); + else metadata.addString(entry.getKey(), (String) value); + } + final T value = + result.getValue() == defaultValue ? defaultValue : mapValue(target, result.getValue()); + final ProviderEvaluation evaluation = + ProviderEvaluation.builder() + .value(value) + .reason(result.getReason()) + .variant(result.getVariant()) + .errorCode( + result.getErrorCode() == null + ? null + : ErrorCode.valueOf(result.getErrorCode().name())) + .errorMessage(result.getErrorMessage()) + .flagMetadata(metadata.build()) + .build(); + if (result.isLogExposure()) { + dispatchExposure(key, evaluation, result.getSerialId(), context); + } + return evaluation; + } + + private static Class coreType(final Class target) { + return target == Value.class ? Object.class : target; + } + + static boolean isAllocationActive(final Allocation allocation, final Instant now) { + return FlagEvaluator.isAllocationActive(allocation, now); + } + + static boolean isTypeCompatible(final Class target, final ValueType type) { + return FlagEvaluator.isTypeCompatible(coreType(target), type); + } + + @SuppressWarnings("unchecked") + static T mapValue(final Class target, final Object value) { + if (target == Value.class) { + return value == null ? null : (T) Value.objectToValue(value); + } + return FlagEvaluator.mapValue(target, value); + } + + private void dispatchExposure( + final String flag, + final ProviderEvaluation evaluation, + final Integer serialId, + final EvaluationContext context) { + final String allocationKey = allocationKey(evaluation); + final String variantKey = evaluation.getVariant(); + if (allocationKey == null || variantKey == null) { + return; + } + final ExposureEvent event = + new ExposureEvent( + System.currentTimeMillis(), + new com.datadog.openfeature.internal.exposure.Allocation(allocationKey), + new com.datadog.openfeature.internal.exposure.Flag(flag), + new com.datadog.openfeature.internal.exposure.Variant(variantKey), + new Subject(context.getTargetingKey(), flattenContext(context)), + serialId); + recordExposure(event); + } + + /** + * Records an exposure to the runtime, if initialized. + * + * @param event the exposure to record. + */ + void recordExposure(final ExposureEvent event) { + final FeatureFlagsRuntime current = this.runtime; + if (current != null) { + current.recordExposure(event); + } + } + + private static String allocationKey(final ProviderEvaluation resolution) { + final ImmutableMetadata meta = resolution.getFlagMetadata(); + return meta == null ? null : meta.getString("allocationKey"); + } + + static AbstractMap flattenContext(final EvaluationContext context) { + return flattenValues(snapshotValues(context)); + } + + static Map snapshotValues(final EvaluationContext context) { + final HashMap values = new HashMap<>(); + final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); + for (final String key : context.keySet()) { + values.put(key, snapshotValue(context.getValue(key), seenContainers, 0)); + } + return values; + } + + private static Value snapshotValue( + final Value value, final Set seenContainers, final int depth) { + if (value == null) { + return null; + } else if (value.isNull()) { + return new Value(); + } else if (value.isBoolean()) { + return new Value(value.asBoolean()); + } else if (value.isNumber()) { + final Object number = value.asObject(); + return number instanceof Integer + ? new Value((Integer) number) + : new Value(((Number) number).doubleValue()); + } else if (value.isString()) { + return new Value(value.asString()); + } else if (value.isInstant()) { + return new Value(value.asInstant()); + } else if (value.isList()) { + final List list = value.asList(); + if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(list)) { + return new Value(); + } + final List snapshot = new ArrayList<>(list.size()); + for (final Value item : list) { + snapshot.add(snapshotValue(item, seenContainers, depth + 1)); + } + seenContainers.remove(list); + return new Value(Collections.unmodifiableList(snapshot)); + } else if (value.isStructure()) { + final Structure structure = value.asStructure(); + if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(structure)) { + return new Value(); + } + final Map snapshot = new HashMap<>(); + for (final String key : structure.keySet()) { + snapshot.put(key, snapshotValue(structure.getValue(key), seenContainers, depth + 1)); + } + seenContainers.remove(structure); + return new Value(new ImmutableStructure(snapshot)); + } + throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); + } + + static AbstractMap flattenValues(final Map values) { + final HashMap result = new HashMap<>(); + final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); + for (final Map.Entry root : values.entrySet()) { + final Deque deque = new LinkedList<>(); + deque.push(new FlattenEntry(root.getKey(), root.getValue())); + while (!deque.isEmpty()) { + final FlattenEntry entry = deque.pop(); + final Value value = entry.value; + if (value == null) { + result.put(entry.key, null); + } else if (value.isList()) { + final List list = value.asList(); + if (seenContainers.add(list)) { + for (int i = 0; i < list.size(); i++) { + deque.push(new FlattenEntry(entry.key + "[" + i + "]", list.get(i))); + } + } + } else if (value.isStructure()) { + final Structure structure = value.asStructure(); + if (seenContainers.add(structure)) { + for (final String property : structure.keySet()) { + deque.push( + new FlattenEntry(entry.key + "." + property, structure.getValue(property))); + } + } + } else { + result.put(entry.key, convertValue(value)); + } + } + } + return result; + } + + private static Object convertValue(final Value value) { + if (value == null || value.isNull()) { + return null; + } else if (value.isBoolean()) { + return value.asBoolean(); + } else if (value.isNumber()) { + return value.asObject(); + } else if (value.isString()) { + return value.asString(); + } else if (value.isInstant()) { + return value.asInstant().toString(); + } + throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); + } + + // Reason-code bitmask constants used by copyPrunedContext to track which caps fired. + static final int REASON_MAX_CONTEXT_FIELDS = 1; + static final int REASON_MAX_KEY_LENGTH = 1 << 1; + static final int REASON_MAX_VALUE_LENGTH = 1 << 2; + static final int REASON_MAX_LIST_ELEMENTS = 1 << 3; + static final int REASON_MAX_STRUCTURE_PROPERTIES = 1 << 4; + static final int REASON_MAX_SNAPSHOT_DEPTH = 1 << 5; + static final int REASON_CYCLE = 1 << 6; + + /** Sorted reason-code strings, indexed by their bit position in the bitmask. */ + private static final String[] REASON_NAMES = { + "max_context_fields", + "max_key_length", + "max_value_length", + "max_list_elements", + "max_structure_properties", + "max_snapshot_depth", + "cycle", + }; + + /** + * Builds the sorted, comma-separated reason tag string from a bitmask of fired reason codes. + * Returns null when no reason bit is set (no truncation occurred). The returned string is ready + * to use directly as the "reason:..." tag value. + */ + static String truncationReasonTag(final int reasonMask) { + if (reasonMask == 0) { + return null; + } + final StringBuilder sb = new StringBuilder(); + for (int bit = 0; bit < REASON_NAMES.length; bit++) { + if ((reasonMask & (1 << bit)) != 0) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(REASON_NAMES[bit]); + } + } + return sb.toString(); + } + + /** + * Result of copyPrunedContext: the pruned attribute map plus an optional reason tag describing + * which caps fired during the walk. truncatedReason is null when no truncation occurred, so + * callers can skip the telemetry path with a single null check. + */ + static final class CopyResult { + final Map attrs; + + /** Non-null when at least one cap fired; ready to use as the "reason:..." tag value. */ + final String truncatedReason; + + CopyResult(final Map attrs, final String truncatedReason) { + this.attrs = attrs; + this.truncatedReason = truncatedReason; + } + } + + /** + * Single-pass bounded copy of a caller-owned EvaluationContext into the flattened, pruned map + * stored on a FlagEvalEvent and later canonicalized by the aggregator. + * + *

    Every retained-size dimension is capped inline so the hot path performs work proportional to + * what is kept, never to what the caller supplied: MAX_CONTEXT_FIELDS - stop iterating the + * top-level context past this many retained fields MAX_KEY_LENGTH - skip fields whose flattened + * key exceeds this length (also enforced on every path segment produced by descending into + * lists/structures) MAX_VALUE_LENGTH - skip string values exceeding this length MAX_LIST_ELEMENTS + * - stop iterating a list past this many elements MAX_STRUCTURE_PROPERTIES - stop iterating a + * structure past this many properties MAX_SNAPSHOT_DEPTH - stop descending into lists/structures + * past this depth Cycle guard - identity-tracked containers currently on the recursion stack are + * treated as leaves + * + *

    All numeric limits are named constants so they can be tuned independently. + * + *

    Returns a CopyResult whose attrs is an empty map for null/empty input and whose + * truncatedReason is non-null when at least one cap fired. The returned map is a plain HashMap; + * canonical-key sorting happens once in the aggregator, off the hot path. + */ + static CopyResult copyPrunedContext(final EvaluationContext context) { + if (context == null) { + return new CopyResult(Collections.emptyMap(), null); + } + final Set keys = context.keySet(); + if (keys.isEmpty()) { + return new CopyResult(Collections.emptyMap(), null); + } + final HashMap out = new HashMap<>(); + final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + final int[] reasonMask = {0}; + for (final String key : keys) { + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + if (EvaluationContext.TARGETING_KEY.equals(key)) { + continue; + } + copyPrunedValue(out, key, context.getValue(key), seen, 0, reasonMask); + } + final Map attrs = out.isEmpty() ? Collections.emptyMap() : out; + return new CopyResult(attrs, truncationReasonTag(reasonMask[0])); + } + + private static void copyPrunedValue( + final Map out, + final String key, + final Value value, + final Set seen, + final int depth, + final int[] reasonMask) { + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + return; + } + if (key.length() > MAX_KEY_LENGTH) { + reasonMask[0] |= REASON_MAX_KEY_LENGTH; + return; + } + if (value == null || value.isNull()) { + out.put(key, null); + return; + } + if (value.isString()) { + final String s = value.asString(); + if (s.length() > MAX_VALUE_LENGTH) { + reasonMask[0] |= REASON_MAX_VALUE_LENGTH; + return; + } + out.put(key, s); + return; + } + if (value.isBoolean() || value.isNumber() || value.isInstant()) { + out.put(key, convertValue(value)); + return; + } + if (value.isList()) { + final List list = value.asList(); + if (depth >= MAX_SNAPSHOT_DEPTH) { + reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; + return; + } + if (!seen.add(list)) { + reasonMask[0] |= REASON_CYCLE; + return; + } + if (list.size() > MAX_LIST_ELEMENTS) { + reasonMask[0] |= REASON_MAX_LIST_ELEMENTS; + } + final int limit = Math.min(list.size(), MAX_LIST_ELEMENTS); + for (int i = 0; i < limit; i++) { + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + copyPrunedValue(out, key + "[" + i + "]", list.get(i), seen, depth + 1, reasonMask); + } + seen.remove(list); + return; + } + if (value.isStructure()) { + final Structure structure = value.asStructure(); + if (depth >= MAX_SNAPSHOT_DEPTH) { + reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; + return; + } + if (!seen.add(structure)) { + reasonMask[0] |= REASON_CYCLE; + return; + } + int walked = 0; + for (final String property : structure.keySet()) { + if (walked >= MAX_STRUCTURE_PROPERTIES) { + reasonMask[0] |= REASON_MAX_STRUCTURE_PROPERTIES; + break; + } + if (out.size() >= MAX_CONTEXT_FIELDS) { + reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; + break; + } + walked++; + copyPrunedValue( + out, key + "." + property, structure.getValue(property), seen, depth + 1, reasonMask); + } + seen.remove(structure); + } + } + + private static class FlattenEntry { + private final String key; + private final Value value; + + private FlattenEntry(final String key, final Value value) { + this.key = key; + this.value = value; + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java similarity index 91% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java index f4c9cacffdb..10176fe09a2 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Evaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Evaluator.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ProviderEvaluation; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java similarity index 79% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java index 322f11ac9e1..2dafb6d89eb 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -1,8 +1,7 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; @@ -28,35 +27,15 @@ *

    This hook is registered alongside the existing OTel FlagEvalMetricsHook - it does NOT replace * it. * - *

    The writer is resolved lazily on each call, so the hook is always safe to register - if the - * writer is absent (killswitch off or not yet started) it is a no-op. + *

    The pipeline is resolved lazily on each call, so the hook is always safe to register - if the + * pipeline is absent (killswitch off or not yet started) it is a no-op. */ class FlagEvalLoggingHook implements Hook { - /** - * Singleton instance: always registered when the provider is created; harmless when writer=null - * (killswitch off or not yet started). - */ - static final FlagEvalLoggingHook INSTANCE = new FlagEvalLoggingHook<>(); - - /** - * Writer resolver. Production instances resolve through FeatureFlaggingGateway; tests can inject - * a direct writer or a resolver that simulates old-bootstrap linkage failures. - */ - private final Supplier writerSupplier; - - /** Production constructor - resolves writer from gateway. */ - FlagEvalLoggingHook() { - this(FeatureFlaggingGateway::getFlagEvalWriter); - } - - /** Test-only constructor - injects a writer directly, bypassing the gateway. */ - FlagEvalLoggingHook(final FlagEvaluationWriter writer) { - this(() -> writer); - } + /** Pipeline resolver, returning {@code null} when evaluation counts are not delivered. */ + private final Supplier writerSupplier; - /** Test-only constructor - injects a writer resolver directly, bypassing the gateway. */ - FlagEvalLoggingHook(final Supplier writerSupplier) { + FlagEvalLoggingHook(final Supplier writerSupplier) { this.writerSupplier = writerSupplier; } @@ -76,11 +55,7 @@ public void finallyAfter( if (details == null) { return; } - if (!FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()) { - return; - } - - final FlagEvaluationWriter w = writerSupplier.get(); + final FlagEvaluationPipeline w = writerSupplier.get(); if (w == null) { return; } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java similarity index 58% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java index eba3b7ba4b5..58b092e87f5 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java @@ -1,7 +1,8 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import dev.openfeature.sdk.ErrorCode; import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; @@ -31,39 +32,84 @@ class FlagEvalMetrics implements Closeable { AttributeKey.stringKey("feature_flag.result.allocation_key"); private volatile LongCounter counter; + private volatile OpenTelemetry telemetry; + private volatile boolean closed; + private volatile Boolean legacyAgentBridge; + private final boolean suppliedCounter; FlagEvalMetrics() { + suppliedCounter = false; + } + + private LongCounter resolveCounter() { + if (closed || suppliedCounter) { + return counter; + } try { - Meter meter = GlobalOpenTelemetry.get().getMeterProvider().meterBuilder(METER_NAME).build(); + OpenTelemetry current = GlobalOpenTelemetry.getOrNoop(); + if (current == OpenTelemetry.noop() && hasLegacyAgentBridge()) { + // Older OTel agents bridge get(), but predate getOrNoop(). Only use get() when the + // agent has injected its API bridge. Mere agent-JAR presence is not sufficient. + current = GlobalOpenTelemetry.get(); + } + if (current == telemetry) { + return counter; + } + return initializeCounter(current); + } catch (LinkageError e) { + log.debug("OpenTelemetry API 1.57 or newer is required for evaluation metrics", e); + shutdown(); + } catch (Exception e) { + log.error("Failed to initialize flag evaluation metrics", e); + } + return null; + } + + private boolean hasLegacyAgentBridge() { + Boolean available = legacyAgentBridge; + if (available == null) { + try { + Class.forName( + "io.opentelemetry.javaagent.instrumentation.opentelemetryapi.ApplicationOpenTelemetry", + false, + GlobalOpenTelemetry.class.getClassLoader()); + available = true; + } catch (ClassNotFoundException | LinkageError | SecurityException ignored) { + available = false; + } + legacyAgentBridge = available; + } + return available; + } + + private synchronized LongCounter initializeCounter(final OpenTelemetry current) { + if (closed) { + return null; + } + if (current != telemetry) { + Meter meter = current.getMeterProvider().meterBuilder(METER_NAME).build(); counter = meter .counterBuilder(METRIC_NAME) .setUnit(METRIC_UNIT) .setDescription(METRIC_DESC) .build(); + telemetry = current; log.debug("Flag evaluation metrics initialized"); - } catch (NoClassDefFoundError e) { - log.error( - "OpenTelemetry API is not on the classpath — evaluation metrics disabled. Add" - + " opentelemetry-api to your dependencies and enable DD_METRICS_OTEL_ENABLED to" - + " export flag evaluation metrics through the Datadog Java agent.", - e); - counter = null; - } catch (Exception e) { - log.error("Failed to initialize flag evaluation metrics", e); - counter = null; } + return counter; } /** Package-private constructor for testing with a mock counter. */ FlagEvalMetrics(LongCounter counter) { + this.suppliedCounter = true; this.counter = counter; } void record( String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) { - LongCounter c = counter; + LongCounter c = resolveCounter(); if (c == null) { return; } @@ -93,7 +139,9 @@ public void close() { shutdown(); } - void shutdown() { + synchronized void shutdown() { + closed = true; counter = null; + telemetry = null; } } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java similarity index 96% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java index a3e0dbfc83c..d63cbe56d8c 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetricsHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetricsHook.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.FlagEvaluationDetails; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java similarity index 82% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java index 17009d9933a..4aff66593ed 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -1,8 +1,12 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static java.util.concurrent.TimeUnit.SECONDS; -import de.thetaphi.forbiddenapis.SuppressForbidden; +import com.datadog.openfeature.internal.FeatureFlagsRuntime; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.Connectors; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; @@ -15,7 +19,6 @@ import dev.openfeature.sdk.exceptions.FatalError; 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; @@ -24,15 +27,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** The Datadog OpenFeature provider. */ 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; + private final Connector connector; + private final Settings settings; private final AtomicReference initializationState = new AtomicReference<>(InitializationState.NOT_STARTED); private final FlagEvalMetrics flagEvalMetrics; @@ -43,10 +48,16 @@ public class Provider extends EventProvider implements Metadata { // once so gate-off evaluation allocates nothing on this hot path. private final List providerHooks; + /** Creates a provider waiting up to 30 seconds for the initial flag configuration. */ public Provider() { this(DEFAULT_OPTIONS, null); } + /** + * Creates a provider. + * + * @param options the provider options. + */ public Provider(final Options options) { this(options, null); } @@ -57,7 +68,7 @@ public Provider(final Options options) { /** * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test - * seam); when null, the gate is read via {@link SpanEnrichmentGate}. + * seam); when null, the gate is read from the settings. */ Provider( final Options options, @@ -65,6 +76,8 @@ public Provider(final Options options) { final Boolean spanEnrichmentEnabledOverride) { this.options = options; this.evaluator = evaluator; + this.connector = Connectors.detect(); + this.settings = Settings.load(this.connector); FlagEvalMetrics metrics = null; FlagEvalMetricsHook hook = null; try { @@ -82,8 +95,9 @@ public Provider(final Options options) { final boolean spanEnrichmentEnabled = spanEnrichmentEnabledOverride != null ? spanEnrichmentEnabledOverride - : SpanEnrichmentGate.isEnabled(); - this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; + : this.settings.isSpanEnrichmentEnabled(); + this.spanEnrichmentHook = + spanEnrichmentEnabled ? new SpanEnrichmentHook(this.connector.spanEnricher()) : null; // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) // allocates nothing, including when the gate is off. @@ -91,16 +105,9 @@ public Provider(final Options options) { if (flagEvalMetricsHook != null) { hooks.add(flagEvalMetricsHook); } - // EVP flagevaluation hook: always registered; no-op when writer is absent (killswitch off). - // Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call. - try { - final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); - if (flagEvalLoggingHook != null) { - hooks.add(flagEvalLoggingHook); - } - } catch (LinkageError | Exception e) { - // Keep older bootstrap/API combinations working: EVP recording is best-effort. - } + // EVP flagevaluation hook: always registered; no-op when the pipeline is absent (killswitch + // off or provider not initialized). The pipeline is resolved lazily on each call. + hooks.add(new FlagEvalLoggingHook<>(this::flagEvaluationPipeline)); if (spanEnrichmentHook != null) { hooks.add(spanEnrichmentHook); } @@ -139,7 +146,8 @@ public void initialize(final EvaluationContext context) throws Exception { throw e; } catch (final Throwable e) { markInitializationError(); - throw new FatalError("Failed to initialize provider, is the tracer configured?", e); + throw new FatalError( + "Failed to initialize Datadog Feature Flags provider: " + e.getMessage(), e); } } @@ -212,13 +220,20 @@ private void markInitializationError() { } } - private Evaluator buildEvaluator() throws Exception { + private Evaluator buildEvaluator() { if (evaluator != null) { return evaluator; } - final Class evaluatorClass = loadEvaluatorClass(); - final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); - return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); + return new DDEvaluator(this::onConfigurationChange, this.connector, this.settings); + } + + private FlagEvaluationPipeline flagEvaluationPipeline() { + final Evaluator current = this.evaluator; + if (!(current instanceof DDEvaluator)) { + return null; + } + final FeatureFlagsRuntime runtime = ((DDEvaluator) current).runtime(); + return runtime == null ? null : runtime.evaluations(); } @Override @@ -226,18 +241,12 @@ public List getProviderHooks() { return providerHooks; } - Hook buildFlagEvalLoggingHook() { - return FlagEvalLoggingHook.INSTANCE; - } - @Override 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(); } @@ -288,11 +297,6 @@ public ProviderEvaluation getObjectEvaluation( return evaluator.evaluate(Value.class, key, defaultValue, ctx); } - @SuppressForbidden // Class#forName(String) used to lazy-load the evaluator implementation - protected Class loadEvaluatorClass() throws ClassNotFoundException { - return Class.forName(EVALUATOR_IMPL); - } - private enum InitializationState { NOT_STARTED, INITIALIZING, @@ -301,11 +305,19 @@ private enum InitializationState { ERROR } + /** The provider options. */ public static class Options { private long timeout; private TimeUnit unit; + /** + * Sets how long provider initialization waits for the initial flag configuration. + * + * @param timeout the timeout. + * @param unit the timeout unit. + * @return these options. + */ public Options initTimeout(final long timeout, final TimeUnit unit) { this.timeout = timeout; this.unit = unit; diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java similarity index 86% rename from products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java index 40e998a6546..525ba9badf9 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java @@ -1,7 +1,6 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; +import com.datadog.openfeature.internal.connector.SpanEnricher; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; @@ -20,9 +19,9 @@ /** * 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. + * each relevant evaluation it records it to the {@link SpanEnricher}; the Datadog Java agent + * 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): * @@ -43,6 +42,12 @@ class SpanEnrichmentHook implements Hook { static final String METADATA_SERIAL_ID = DDEvaluator.METADATA_SPLIT_SERIAL_ID; static final String METADATA_DO_LOG = DDEvaluator.METADATA_DO_LOG; + private final SpanEnricher enricher; + + SpanEnrichmentHook(final SpanEnricher enricher) { + this.enricher = enricher; + } + @Override public void finallyAfter( final HookContext ctx, @@ -56,14 +61,11 @@ public void finallyAfter( 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))); + this.enricher.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()))); + // Value to a native Java type here so the connector carries only JDK types. + this.enricher.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. diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java new file mode 100644 index 00000000000..cadbbd0d8b2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java @@ -0,0 +1,18 @@ +package com.datadog.openfeature.internal; + +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.util.function.Consumer; + +/** Delivers flag configurations to the runtime. */ +public interface ConfigurationService extends AutoCloseable { + /** + * Starts delivering configurations. Implementations may deliver the first configuration before + * returning. + * + * @param listener the listener receiving configurations, or {@code null} when removed. + */ + void start(Consumer listener); + + @Override + void close(); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java new file mode 100644 index 00000000000..2bea9fb33e9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java @@ -0,0 +1,107 @@ +package com.datadog.openfeature.internal; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; +import java.util.function.Consumer; + +/** + * A bounded, lock-free, multiple-producer single-consumer queue. Producers never block; the + * consumer polls with a timeout by parking, as producers do not signal it. + * + * @param the element type. + */ +public final class EventQueue { + private static final long PARK_NANOS = TimeUnit.MILLISECONDS.toNanos(10); + + private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); + private final AtomicInteger size = new AtomicInteger(); + private final int capacity; + + public EventQueue(final int capacity) { + this.capacity = capacity; + } + + /** + * Offers an element without blocking. + * + * @param element the element to offer. + * @return {@code true} if the element was queued, {@code false} if the queue is full. + */ + public boolean offer(final E element) { + if (this.size.incrementAndGet() > this.capacity) { + this.size.decrementAndGet(); + return false; + } + this.queue.offer(element); + return true; + } + + /** + * @return the head element, or {@code null} if the queue is empty. + */ + public E poll() { + final E element = this.queue.poll(); + if (element != null) { + this.size.decrementAndGet(); + } + return element; + } + + /** + * Polls the head element, waiting up to the given timeout for one to be available. + * + * @param timeout the maximum time to wait. + * @param unit the timeout unit. + * @return the head element, or {@code null} if none was available in time. + * @throws InterruptedException if the consumer thread is interrupted while waiting. + */ + public E poll(final long timeout, final TimeUnit unit) throws InterruptedException { + final long deadline = System.nanoTime() + unit.toNanos(timeout); + while (true) { + final E element = poll(); + if (element != null) { + return element; + } + final long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + return null; + } + LockSupport.parkNanos(this, Math.min(remaining, PARK_NANOS)); + if (Thread.interrupted()) { + throw new InterruptedException(); + } + } + } + + /** + * Drains up to {@code limit} elements to the consumer. + * + * @param consumer the consumer of the drained elements. + * @param limit the maximum number of elements to drain. + */ + public void drain(final Consumer consumer, final int limit) { + for (int i = 0; i < limit; i++) { + final E element = poll(); + if (element == null) { + return; + } + consumer.accept(element); + } + } + + /** + * @return the current number of queued elements. + */ + public int size() { + return this.size.get(); + } + + /** + * @return the queue capacity. + */ + public int capacity() { + return this.capacity; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java new file mode 100644 index 00000000000..f4ed6236c0f --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -0,0 +1,227 @@ +package com.datadog.openfeature.internal; + +import static com.datadog.openfeature.internal.config.Settings.CONFIGURATION_SOURCE_REMOTE_CONFIG; + +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.delivery.CdnConfigurationSource; +import com.datadog.openfeature.internal.delivery.DirectIntakeTransport; +import com.datadog.openfeature.internal.delivery.FallbackTransport; +import com.datadog.openfeature.internal.delivery.RemoteConfigurationService; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.exposure.ExposurePipeline; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The Feature Flags runtime: configuration delivery and event pipelines. A single runtime is shared + * by all the providers of a class loader, and stopped when the last one releases it. + */ +public final class FeatureFlagsRuntime { + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagsRuntime.class); + + private static final Object SHARED_LOCK = new Object(); + private static FeatureFlagsRuntime shared; + private static int references; + + private final ConfigurationService source; + @Nullable private final ExposurePipeline exposures; + @Nullable private final FlagEvaluationPipeline evaluations; + private final List> listeners = new CopyOnWriteArrayList<>(); + private volatile ServerConfiguration configuration; + + FeatureFlagsRuntime( + final ConfigurationService source, + @Nullable final ExposurePipeline exposures, + @Nullable final FlagEvaluationPipeline evaluations) { + this.source = source; + this.exposures = exposures; + this.evaluations = evaluations; + } + + /** + * Acquires the shared runtime, starting it on first acquisition. Each acquisition must be paired + * with a {@link #release()}. + * + * @param connector the connector to the Datadog Java agent. + * @param settings the SDK settings. + * @return the shared runtime. + * @throws IllegalStateException if Feature Flags is disabled or misconfigured. + */ + public static FeatureFlagsRuntime acquire(final Connector connector, final Settings settings) { + synchronized (SHARED_LOCK) { + if (shared == null) { + shared = create(connector, settings); + shared.start(); + } + references++; + return shared; + } + } + + /** Releases the shared runtime, stopping it on last release. */ + public static void release() { + final FeatureFlagsRuntime stopped; + synchronized (SHARED_LOCK) { + if (references == 0 || --references > 0) { + return; + } + stopped = shared; + shared = null; + } + stopped.stop(); + } + + static FeatureFlagsRuntime create(final Connector connector, final Settings settings) { + final String sourceName = settings.configurationSource(); + if (sourceName == null) { + throw new IllegalStateException("Feature Flags is disabled"); + } + final boolean remoteConfig = CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(sourceName); + final RuntimeServices services = new RuntimeServices(connector.healthMetrics()); + final ConfigurationService source; + final EventTransport transport; + if (remoteConfig) { + // TODO Support Remote Configuration without the Datadog Java agent. + final ConfigurationSource remoteConfiguration = connector.remoteConfiguration(); + if (remoteConfiguration == null) { + throw new IllegalStateException( + "Feature Flags Remote Configuration source requires the Datadog Java agent with" + + " Remote Configuration enabled"); + } + source = new RemoteConfigurationService(remoteConfiguration); + // Remote Configuration never falls back to direct intake. + transport = connector.eventProxy(); + } else { + source = new CdnConfigurationSource(settings, services); + final EventTransport proxy = connector.eventProxy(); + final EventTransport direct = DirectIntakeTransport.create(settings); + transport = + proxy == null ? direct : direct == null ? proxy : new FallbackTransport(proxy, direct); + } + if (transport == null) { + LOGGER.warn( + "Feature Flags event delivery is disabled: set DD_API_KEY or attach the Datadog Java agent"); + return new FeatureFlagsRuntime(source, null, null); + } + final Map context = eventContext(settings); + final ExposurePipeline exposures = + new ExposurePipeline( + ExposurePipeline.DEFAULT_CAPACITY, + ExposurePipeline.DEFAULT_FLUSH_INTERVAL_IN_SECONDS, + TimeUnit.SECONDS, + transport, + context, + services); + final FlagEvaluationPipeline evaluations = + settings.isEvaluationCountsEnabled() + ? new FlagEvaluationPipeline( + FlagEvaluationPipeline.DEFAULT_CAPACITY, + FlagEvaluationPipeline.FLUSH_INTERVAL_SECONDS, + TimeUnit.SECONDS, + transport, + context, + services) + : null; + return new FeatureFlagsRuntime(source, exposures, evaluations); + } + + private static Map eventContext(final Settings settings) { + final Map context = new HashMap<>(); + putIfNotNull(context, "service", settings.service()); + putIfNotNull(context, "env", settings.env()); + putIfNotNull(context, "version", settings.version()); + return context; + } + + private static void putIfNotNull( + final Map map, final String key, @Nullable final String value) { + if (value != null) { + map.put(key, value); + } + } + + void start() { + try { + if (this.exposures != null) { + this.exposures.start(); + } + if (this.evaluations != null) { + this.evaluations.start(); + } + this.source.start(this::onConfiguration); + } catch (final RuntimeException | Error e) { + stop(); + throw e; + } + } + + void stop() { + this.source.close(); + if (this.evaluations != null) { + this.evaluations.close(); + } + if (this.exposures != null) { + this.exposures.close(); + } + } + + private void onConfiguration(@Nullable final ServerConfiguration configuration) { + this.configuration = configuration; + for (final Consumer listener : this.listeners) { + listener.accept(configuration); + } + } + + /** + * Adds a configuration listener, receiving the current configuration if any. + * + * @param listener the listener to add. + */ + public void addConfigurationListener(final Consumer listener) { + this.listeners.add(listener); + final ServerConfiguration current = this.configuration; + if (current != null) { + listener.accept(current); + } + } + + /** + * Removes a configuration listener. + * + * @param listener the listener to remove. + */ + public void removeConfigurationListener(final Consumer listener) { + this.listeners.remove(listener); + } + + /** + * Records an exposure, if exposure delivery is enabled. + * + * @param exposure the exposure to record. + */ + public void recordExposure(final ExposureEvent exposure) { + if (this.exposures != null) { + this.exposures.accept(exposure); + } + } + + /** + * @return the flag evaluation pipeline, or {@code null} if evaluation counts are disabled. + */ + @Nullable + public FlagEvaluationPipeline evaluations() { + return this.evaluations; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/JsonWriting.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/JsonWriting.java new file mode 100644 index 00000000000..807f76ab428 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/JsonWriting.java @@ -0,0 +1,96 @@ +package com.datadog.openfeature.internal; + +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Collection; +import java.util.Map; +import javax.annotation.Nullable; + +/** JSON serialization helpers for event payloads. */ +public final class JsonWriting { + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + + private JsonWriting() {} + + /** + * Serializes a JSON document. + * + * @param writer the document writer. + * @return the UTF-8 encoded document. + */ + public static byte[] write(final Writer writer) { + final ByteArrayOutputStream output = new ByteArrayOutputStream(256); + try (JsonGenerator generator = JSON_FACTORY.createGenerator(output, JsonEncoding.UTF8)) { + writer.write(generator); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + return output.toByteArray(); + } + + /** + * Writes an arbitrary value: maps, collections, strings, numbers, booleans and {@code null}. + * Other values are written with their string representation. + * + * @param generator the generator to write to. + * @param value the value to write. + * @throws IOException if the value could not be written. + */ + public static void writeValue(final JsonGenerator generator, @Nullable final Object value) + throws IOException { + if (value == null) { + generator.writeNull(); + } else if (value instanceof String) { + generator.writeString((String) value); + } else if (value instanceof Boolean) { + generator.writeBoolean((Boolean) value); + } else if (value instanceof Integer || value instanceof Long || value instanceof Short) { + generator.writeNumber(((Number) value).longValue()); + } else if (value instanceof Number) { + generator.writeNumber(((Number) value).doubleValue()); + } else if (value instanceof Map) { + generator.writeStartObject(); + for (final Map.Entry entry : ((Map) value).entrySet()) { + generator.writeFieldName(String.valueOf(entry.getKey())); + writeValue(generator, entry.getValue()); + } + generator.writeEndObject(); + } else if (value instanceof Collection) { + generator.writeStartArray(); + for (final Object element : (Collection) value) { + writeValue(generator, element); + } + generator.writeEndArray(); + } else { + generator.writeString(value.toString()); + } + } + + /** + * Writes a {@code {"key": ...}} object field, skipped when the key is {@code null}. + * + * @param generator the generator to write to. + * @param fieldName the field name. + * @param key the key value. + * @throws IOException if the field could not be written. + */ + public static void writeKeyObject( + final JsonGenerator generator, final String fieldName, @Nullable final String key) + throws IOException { + if (key != null) { + generator.writeObjectFieldStart(fieldName); + generator.writeStringField("key", key); + generator.writeEndObject(); + } + } + + /** Writes a JSON document. */ + @FunctionalInterface + public interface Writer { + void write(JsonGenerator generator) throws IOException; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java new file mode 100644 index 00000000000..49cd87d9dcc --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java @@ -0,0 +1,40 @@ +package com.datadog.openfeature.internal; + +import com.datadog.openfeature.internal.connector.HealthMetrics; +import javax.annotation.Nullable; + +/** Thread creation and health metrics shared by the runtime components. */ +public final class RuntimeServices { + private final HealthMetrics healthMetrics; + + public RuntimeServices(final HealthMetrics healthMetrics) { + this.healthMetrics = healthMetrics; + } + + /** + * Creates a daemon thread. + * + * @param role the thread role, used to name the thread. + * @param task the thread task. + * @return the created thread, not started yet. + */ + public Thread newThread(final String role, final Runnable task) { + final Thread thread = new Thread(task, "dd-openfeature-" + role); + thread.setDaemon(true); + thread.setContextClassLoader(null); + return thread; + } + + /** + * Increments a health counter, skipping non-positive values. + * + * @param name the metric name. + * @param value the increment. + * @param reason the optional {@code reason} tag value. + */ + public void countMetric(final String name, final long value, @Nullable final String reason) { + if (value > 0) { + this.healthMetrics.count(name, value, reason); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java new file mode 100644 index 00000000000..c100d98e400 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java @@ -0,0 +1,30 @@ +package com.datadog.openfeature.internal.config; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** The version of this SDK, as written by the build. */ +public final class SdkVersion { + /** The SDK version, or {@code unknown} if the version file is missing. */ + public static final String VERSION = readVersion(); + + private SdkVersion() {} + + private static String readVersion() { + try (InputStream stream = SdkVersion.class.getResourceAsStream("/dd-openfeature.version")) { + if (stream == null) { + return "unknown"; + } + final String line = + new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).readLine(); + // Strip the git hash suffix. + final int hash = line == null ? -1 : line.indexOf('~'); + return line == null ? "unknown" : hash < 0 ? line.trim() : line.substring(0, hash); + } catch (final IOException e) { + return "unknown"; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java new file mode 100644 index 00000000000..1c93647f1f5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -0,0 +1,246 @@ +package com.datadog.openfeature.internal.config; + +import com.datadog.openfeature.internal.connector.Connector; +import java.util.Locale; +import java.util.function.UnaryOperator; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Resolves the SDK settings. A setting is looked up from the {@link Connector} first, then from the + * {@code dd.}-prefixed system property, then from the {@code DD_}-prefixed environment variable. + */ +public final class Settings { + private static final Logger LOGGER = LoggerFactory.getLogger(Settings.class); + + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; + public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; + + static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; + static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; + static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = + "experimental.flagging.provider.enabled"; + static final String AGENTLESS_BASE_URL = "feature.flags.configuration.source.agentless.base.url"; + static final String AGENTLESS_POLL_INTERVAL_SECONDS = + "feature.flags.configuration.source.agentless.poll.interval.seconds"; + static final String AGENTLESS_REQUEST_TIMEOUT_SECONDS = + "feature.flags.configuration.source.agentless.request.timeout.seconds"; + static final String SPAN_ENRICHMENT_ENABLED = + "experimental.flagging.provider.span.enrichment.enabled"; + static final String EVALUATION_COUNTS_ENABLED = "flagging.evaluation.counts.enabled"; + static final String API_KEY = "api-key"; + static final String SITE = "site"; + static final String ENV = "env"; + static final String SERVICE = "service"; + static final String VERSION = "version"; + + static final String DEFAULT_SITE = "datadoghq.com"; + static final int DEFAULT_POLL_INTERVAL_SECONDS = 30; + static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 5; + + private final Connector connector; + private final UnaryOperator systemProperties; + private final UnaryOperator environment; + + Settings( + final Connector connector, + final UnaryOperator systemProperties, + final UnaryOperator environment) { + this.connector = connector; + this.systemProperties = systemProperties; + this.environment = environment; + } + + /** + * Creates the settings backed by the given connector, system properties and environment. + * + * @param connector the connector to look settings up from first. + * @return the settings. + */ + public static Settings load(final Connector connector) { + return new Settings(connector, System::getProperty, System::getenv); + } + + /** + * @return whether Feature Flags is enabled. + */ + public boolean isEnabled() { + return resolveSource() != null; + } + + /** + * Resolves the configuration source, with the same semantics as the Datadog Java agent: an + * explicit {@code DD_FEATURE_FLAGS_ENABLED=false} disables the product, an explicit source wins + * over the legacy {@code DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED} toggle, and {@code agentless} + * is the default. + * + * @return the configuration source, or {@code null} if Feature Flags is disabled or the source is + * not supported. + */ + @Nullable + public String configurationSource() { + return resolveSource(); + } + + @Nullable + private String resolveSource() { + final Boolean enabled = getBoolean(FEATURE_FLAGS_ENABLED); + final String source = normalize(get(FEATURE_FLAGS_CONFIGURATION_SOURCE)); + if (Boolean.FALSE.equals(enabled)) { + return null; + } + if (source != null) { + if (CONFIGURATION_SOURCE_AGENTLESS.equals(source) + || CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(source)) { + return source; + } + LOGGER.warn("Unsupported Feature Flags configuration source: {}", source); + return null; + } + final Boolean legacyEnabled = getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); + if (legacyEnabled != null) { + return legacyEnabled ? CONFIGURATION_SOURCE_REMOTE_CONFIG : null; + } + return CONFIGURATION_SOURCE_AGENTLESS; + } + + /** + * @return the custom CDN base URL, or {@code null} to use the Datadog managed one. + */ + @Nullable + public String agentlessBaseUrl() { + final String url = get(AGENTLESS_BASE_URL); + return url == null || url.trim().isEmpty() ? null : url.trim(); + } + + /** + * @return the CDN poll interval, in seconds. + */ + public int agentlessPollIntervalSeconds() { + return getPositiveInt(AGENTLESS_POLL_INTERVAL_SECONDS, DEFAULT_POLL_INTERVAL_SECONDS); + } + + /** + * @return the CDN request timeout, in seconds. + */ + public int agentlessRequestTimeoutSeconds() { + return getPositiveInt(AGENTLESS_REQUEST_TIMEOUT_SECONDS, DEFAULT_REQUEST_TIMEOUT_SECONDS); + } + + /** + * @return whether span enrichment is enabled (off by default). + */ + public boolean isSpanEnrichmentEnabled() { + return Boolean.TRUE.equals(getBoolean(SPAN_ENRICHMENT_ENABLED)); + } + + /** + * @return whether flag evaluation counts are sent to the event platform (on by default). + */ + public boolean isEvaluationCountsEnabled() { + return !Boolean.FALSE.equals(getBoolean(EVALUATION_COUNTS_ENABLED)); + } + + /** + * @return the Datadog API key, or {@code null} if not set. + */ + @Nullable + public String apiKey() { + return nonBlank(get(API_KEY)); + } + + /** + * @return the Datadog site. + */ + public String site() { + final String site = nonBlank(get(SITE)); + return site == null ? DEFAULT_SITE : site; + } + + /** + * @return the environment, or {@code null} if not set. + */ + @Nullable + public String env() { + return nonBlank(get(ENV)); + } + + /** + * @return the service name, or {@code null} if not set. + */ + @Nullable + public String service() { + return nonBlank(get(SERVICE)); + } + + /** + * @return the application version, or {@code null} if not set. + */ + @Nullable + public String version() { + return nonBlank(get(VERSION)); + } + + @Nullable + String get(final String key) { + String value = this.connector.setting(key); + if (value == null) { + value = this.systemProperties.apply("dd." + key); + } + if (value == null) { + value = this.environment.apply(environmentVariableName(key)); + } + return value; + } + + @Nullable + private Boolean getBoolean(final String key) { + final String value = normalize(get(key)); + if (value == null) { + return null; + } + // Same parsing as the agent: invalid values are ignored. + if ("true".equals(value) || "1".equals(value)) { + return Boolean.TRUE; + } + if ("false".equals(value) || "0".equals(value)) { + return Boolean.FALSE; + } + LOGGER.warn("Invalid boolean value for {}: {}", key, value); + return null; + } + + private int getPositiveInt(final String key, final int defaultValue) { + final String value = nonBlank(get(key)); + if (value == null) { + return defaultValue; + } + try { + final int parsed = Integer.parseInt(value); + return parsed > 0 ? parsed : defaultValue; + } catch (final NumberFormatException e) { + LOGGER.warn("Invalid value for {}: {}", key, value); + return defaultValue; + } + } + + static String environmentVariableName(final String key) { + return "DD_" + key.replace('.', '_').replace('-', '_').toUpperCase(Locale.ROOT); + } + + @Nullable + private static String normalize(@Nullable final String value) { + final String trimmed = nonBlank(value); + return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT); + } + + @Nullable + private static String nonBlank(@Nullable final String value) { + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java new file mode 100644 index 00000000000..21307031fe7 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java @@ -0,0 +1,15 @@ +package com.datadog.openfeature.internal.connector; + +import java.util.function.Consumer; + +/** A source of Universal Flag Configuration (UFC) documents. */ +public interface ConfigurationSource { + /** + * Subscribes to configuration updates. + * + * @param listener the listener receiving raw UFC JSON documents, or {@code null} when the + * configuration is removed. + * @return the handle to close to unsubscribe. + */ + AutoCloseable subscribe(Consumer listener); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java new file mode 100644 index 00000000000..d67c36f9fb7 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java @@ -0,0 +1,41 @@ +package com.datadog.openfeature.internal.connector; + +import javax.annotation.Nullable; + +/** Components supplied by the Datadog Java agent when attached, or no-op ones otherwise. */ +public interface Connector { + /** The connector used when no Datadog Java agent is attached. */ + Connector NONE = new NoConnector(); + + /** + * Looks up a setting from the agent configuration. + * + * @param key the setting key, using the {@code dd.} system property notation without the prefix. + * @return the setting value, or {@code null} to fall back to system properties and environment. + */ + @Nullable + String setting(String key); + + /** + * @return the Remote Configuration source, or {@code null} if not available. + */ + @Nullable + ConfigurationSource remoteConfiguration(); + + /** + * @return the transport through the Datadog Agent event platform proxy, or {@code null} if not + * available. + */ + @Nullable + EventTransport eventProxy(); + + /** + * @return the health metrics sink. + */ + HealthMetrics healthMetrics(); + + /** + * @return the span enricher. + */ + SpanEnricher spanEnricher(); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java new file mode 100644 index 00000000000..cf9be77a214 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java @@ -0,0 +1,16 @@ +package com.datadog.openfeature.internal.connector; + +/** Resolves the {@link Connector} to use. */ +public final class Connectors { + private Connectors() {} + + /** + * Detects the connector to use. The Datadog Java agent instruments this method to return its own + * connector. + * + * @return the connector to use. + */ + public static Connector detect() { + return Connector.NONE; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java new file mode 100644 index 00000000000..c85887214fd --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java @@ -0,0 +1,13 @@ +package com.datadog.openfeature.internal.connector; + +import java.io.IOException; + +/** + * Thrown by an event proxy {@link EventTransport} when the proxy definitively rejects the payload + * as unavailable, so the SDK can fall back to direct intake. + */ +public final class EventProxyUnavailableException extends IOException { + public EventProxyUnavailableException(final String message) { + super(message); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java new file mode 100644 index 00000000000..0bdfb355590 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java @@ -0,0 +1,16 @@ +package com.datadog.openfeature.internal.connector; + +import java.io.IOException; + +/** Delivers serialized product events to the Datadog event platform. */ +@FunctionalInterface +public interface EventTransport { + /** + * Posts a JSON payload. + * + * @param route the event platform route, like {@code exposures} or {@code flagevaluation}. + * @param json the UTF-8 JSON payload. + * @throws IOException if the payload could not be delivered. + */ + void post(String route, byte[] json) throws IOException; +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java new file mode 100644 index 00000000000..fcb0e99f558 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java @@ -0,0 +1,19 @@ +package com.datadog.openfeature.internal.connector; + +import javax.annotation.Nullable; + +/** Records SDK health counters. */ +@FunctionalInterface +public interface HealthMetrics { + /** The sink discarding all counters. */ + HealthMetrics NOOP = (metric, value, reason) -> {}; + + /** + * Increments a counter. + * + * @param metric the metric name. + * @param value the increment. + * @param reason the optional {@code reason} tag value. + */ + void count(String metric, long value, @Nullable String reason); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java new file mode 100644 index 00000000000..1f48b8b05ff --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java @@ -0,0 +1,33 @@ +package com.datadog.openfeature.internal.connector; + +import javax.annotation.Nullable; + +final class NoConnector implements Connector { + @Nullable + @Override + public String setting(final String key) { + return null; + } + + @Nullable + @Override + public ConfigurationSource remoteConfiguration() { + return null; + } + + @Nullable + @Override + public EventTransport eventProxy() { + return null; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java new file mode 100644 index 00000000000..2d744b891f2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java @@ -0,0 +1,34 @@ +package com.datadog.openfeature.internal.connector; + +import javax.annotation.Nullable; + +/** Reflects flag evaluations onto the active local root span. */ +public interface SpanEnricher { + /** The enricher discarding all evaluations. */ + SpanEnricher NOOP = + new SpanEnricher() { + @Override + public void serialId( + final int serialId, final boolean doLog, @Nullable final String targetingKey) {} + + @Override + public void runtimeDefault(final String flagKey, @Nullable final Object defaultValue) {} + }; + + /** + * Records an evaluation that resolved to a split with a serial id. + * + * @param serialId the split serial id. + * @param doLog whether the allocation logs exposures, to also record the subject. + * @param targetingKey the optional targeting key of the subject. + */ + void serialId(int serialId, boolean doLog, @Nullable String targetingKey); + + /** + * Records an evaluation that resolved to its runtime default value. + * + * @param flagKey the flag key. + * @param defaultValue the default value, unwrapped to a native Java type (map, list or scalar). + */ + void runtimeDefault(String flagKey, @Nullable Object defaultValue); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java new file mode 100644 index 00000000000..98469feaeb0 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java @@ -0,0 +1,8 @@ +/** + * Extension points the Datadog Java agent fills in through instrumentation. + * + *

    These types are not customer API. They form the contract between this SDK and the agent + * instrumentation, which can run a different version than the SDK, so they must evolve in a + * backward compatible way. + */ +package com.datadog.openfeature.internal.connector; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java new file mode 100644 index 00000000000..ae14f2bb224 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -0,0 +1,342 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.ConfigurationService; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.DoubleSupplier; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Polls the flag configuration from the Datadog managed CDN, or a custom HTTP endpoint. */ +public final class CdnConfigurationSource implements ConfigurationService { + private static final Logger LOGGER = LoggerFactory.getLogger(CdnConfigurationSource.class); + + static final String UFC_RULES_BASED_SERVER_PATH = + "/api/v2/feature-flagging/config/rules-based/server"; + static final int MAX_ATTEMPTS = 3; + private static final long FIRST_RETRY_MIN_MILLIS = 2_000; + private static final long FIRST_RETRY_MAX_MILLIS = 10_000; + private static final long SECOND_RETRY_MIN_MILLIS = 5_000; + private static final long SECOND_RETRY_MAX_MILLIS = 30_000; + private static final double RETRY_JITTER = 0.2; + private static final long WARNING_INTERVAL_NANOS = TimeUnit.MINUTES.toNanos(5); + + private final URI endpoint; + @Nullable private final String apiKey; + private final long pollIntervalMillis; + private final Duration requestTimeout; + private final HttpClient httpClient; + private final ScheduledExecutorService executor; + private final RetrySleeper retrySleeper; + private final DoubleSupplier jitter; + private final Object lifecycleLock = new Object(); + private final AtomicBoolean polling = new AtomicBoolean(); + private volatile Consumer listener; + private volatile boolean closed; + private volatile boolean started; + private volatile ScheduledFuture scheduledPoll; + private volatile Thread pollingThread; + private volatile String etag; + private long lastWarningNanos; + + public CdnConfigurationSource(final Settings settings, final RuntimeServices services) { + this( + endpoint(settings), + isManagedEndpoint(settings) ? settings.apiKey() : null, + TimeUnit.SECONDS.toMillis(settings.agentlessPollIntervalSeconds()), + Duration.ofSeconds(settings.agentlessRequestTimeoutSeconds()), + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(settings.agentlessRequestTimeoutSeconds())) + .followRedirects(HttpClient.Redirect.NEVER) + .build(), + Executors.newSingleThreadScheduledExecutor( + task -> services.newThread("configuration", task)), + TimeUnit.MILLISECONDS::sleep, + () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); + } + + CdnConfigurationSource( + final URI endpoint, + @Nullable final String apiKey, + final long pollIntervalMillis, + final Duration requestTimeout, + final HttpClient httpClient, + final ScheduledExecutorService executor, + final RetrySleeper retrySleeper, + final DoubleSupplier jitter) { + this.endpoint = endpoint; + this.apiKey = apiKey; + this.pollIntervalMillis = pollIntervalMillis; + this.requestTimeout = requestTimeout; + this.httpClient = httpClient; + this.executor = executor; + this.retrySleeper = retrySleeper; + this.jitter = jitter; + } + + @Override + public void start(final Consumer listener) { + synchronized (this.lifecycleLock) { + if (this.closed || this.started) { + return; + } + this.started = true; + this.listener = listener; + } + // Complete the first poll cycle on the calling thread so provider initialization observes a + // successful retry before it checks whether configuration is ready. + pollOnceSafely(); + synchronized (this.lifecycleLock) { + if (!this.closed) { + this.scheduledPoll = + this.executor.scheduleWithFixedDelay( + this::pollOnceSafely, + this.pollIntervalMillis, + this.pollIntervalMillis, + TimeUnit.MILLISECONDS); + } + } + } + + @Override + public void close() { + final ScheduledFuture poll; + synchronized (this.lifecycleLock) { + if (this.closed) { + return; + } + this.closed = true; + poll = this.scheduledPoll; + this.scheduledPoll = null; + } + if (poll != null) { + poll.cancel(true); + } + final Thread activePollingThread = this.pollingThread; + if (activePollingThread != null) { + activePollingThread.interrupt(); + } + this.executor.shutdownNow(); + } + + /** Test seam: sets the configuration listener without starting the polling. */ + void setListenerForTest(final Consumer listener) { + this.listener = listener; + } + + /** + * Polls the endpoint once. + * + * @return {@code true} if the configuration was applied or is unchanged, {@code false} otherwise. + */ + boolean pollOnce() { + if (this.closed || !this.polling.compareAndSet(false, true)) { + return false; + } + this.pollingThread = Thread.currentThread(); + try { + return fetchAndApply(); + } finally { + this.pollingThread = null; + this.polling.set(false); + } + } + + private void pollOnceSafely() { + try { + pollOnce(); + } catch (final RuntimeException e) { + LOGGER.debug("Unexpected error while polling the Feature Flags configuration", e); + } + } + + private boolean fetchAndApply() { + final HttpResponse response; + try { + response = fetchWithRetries(); + } catch (final IOException e) { + if (!this.closed) { + warn("Feature Flags configuration request failed after {} attempts", MAX_ATTEMPTS, e); + } + return false; + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + if (this.closed) { + return false; + } + final int status = response.statusCode(); + if (isRetryableStatus(status)) { + warn( + "Feature Flags configuration endpoint failed after {} attempts with HTTP {}", + MAX_ATTEMPTS, + status); + return false; + } + synchronized (this.lifecycleLock) { + return !this.closed && apply(response); + } + } + + private boolean apply(final HttpResponse response) { + final int status = response.statusCode(); + if (status == HttpURLConnection.HTTP_NOT_MODIFIED) { + return true; + } + if (status == HttpURLConnection.HTTP_UNAUTHORIZED + || status == HttpURLConnection.HTTP_FORBIDDEN) { + warn( + "Feature Flags configuration endpoint returned HTTP {}; verify endpoint authentication", + status); + return false; + } + if (status != HttpURLConnection.HTTP_OK || response.body() == null) { + return false; + } + final ServerConfiguration configuration; + try { + configuration = UniversalFlagConfigParser.parseJsonApi(Gzip.decode(response)); + } catch (final IOException | RuntimeException e) { + LOGGER.debug("Feature Flags configuration endpoint returned a malformed payload", e); + return false; + } + if (configuration == null) { + return false; + } + this.listener.accept(configuration); + final String nextEtag = response.headers().firstValue("ETag").orElse(null); + this.etag = nextEtag == null || nextEtag.trim().isEmpty() ? null : nextEtag; + return true; + } + + private HttpResponse fetchWithRetries() throws IOException, InterruptedException { + final HttpRequest request = buildRequest(); + int attempt = 0; + while (true) { + attempt++; + try { + final HttpResponse response = + this.httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (!isRetryableStatus(response.statusCode()) || attempt == MAX_ATTEMPTS || this.closed) { + return response; + } + } catch (final IOException e) { + if (attempt == MAX_ATTEMPTS || this.closed || Thread.currentThread().isInterrupted()) { + throw e; + } + } + this.retrySleeper.sleep( + retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble())); + } + } + + private HttpRequest buildRequest() { + final HttpRequest.Builder request = + HttpRequest.newBuilder(this.endpoint) + .timeout(this.requestTimeout) + .header("Accept-Encoding", "gzip"); + Headers.addMetadata(request); + if (this.apiKey != null) { + request.header("DD-API-KEY", this.apiKey); + } + final String currentEtag = this.etag; + if (currentEtag != null) { + request.header("If-None-Match", currentEtag); + } + return request.GET().build(); + } + + private synchronized void warn(final String message, final Object... arguments) { + final long now = System.nanoTime(); + if (this.lastWarningNanos == 0 || now - this.lastWarningNanos >= WARNING_INTERVAL_NANOS) { + this.lastWarningNanos = now; + LOGGER.warn(message, arguments); + } else { + LOGGER.debug(message, arguments); + } + } + + static boolean isRetryableStatus(final int status) { + return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT + || status == 429 + || (status >= 500 && status <= 599); + } + + static URI endpoint(final Settings settings) { + final String configuredBaseUrl = settings.agentlessBaseUrl(); + if (configuredBaseUrl == null) { + final String env = settings.env(); + return URI.create( + "https://" + + managedHost(settings) + + UFC_RULES_BASED_SERVER_PATH + + (env == null ? "" : "?dd_env=" + URLEncoder.encode(env, StandardCharsets.UTF_8))); + } + final URI parsed; + try { + parsed = URI.create(configuredBaseUrl); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid Feature Flags configuration source URL: " + configuredBaseUrl, e); + } + if (parsed.getScheme() == null || parsed.getHost() == null) { + throw new IllegalArgumentException( + "Invalid Feature Flags configuration source URL: " + configuredBaseUrl); + } + final String path = parsed.getRawPath(); + if (path == null || path.isEmpty() || "/".equals(path)) { + return parsed.resolve(UFC_RULES_BASED_SERVER_PATH); + } + return parsed; + } + + private static boolean isManagedEndpoint(final Settings settings) { + return settings.agentlessBaseUrl() == null; + } + + private static String managedHost(final Settings settings) { + return "ufc-server.ff-cdn." + settings.site(); + } + + static long retryDelayMillis( + final long pollIntervalMillis, final int attempt, final double jitter) { + final long baseDelay; + if (attempt == 1) { + baseDelay = clamp(pollIntervalMillis / 6, FIRST_RETRY_MIN_MILLIS, FIRST_RETRY_MAX_MILLIS); + } else if (attempt == 2) { + baseDelay = clamp(pollIntervalMillis / 3, SECOND_RETRY_MIN_MILLIS, SECOND_RETRY_MAX_MILLIS); + } else { + throw new IllegalArgumentException("Unsupported Feature Flags retry attempt: " + attempt); + } + return Math.max(1, Math.round(baseDelay * jitter)); + } + + private static long clamp(final long value, final long minimum, final long maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + @FunctionalInterface + interface RetrySleeper { + void sleep(long delayMillis) throws InterruptedException; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java new file mode 100644 index 00000000000..ef328696efe --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java @@ -0,0 +1,103 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.EventTransport; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; + +/** Posts events directly to the Datadog event platform intake, authenticated with an API key. */ +public final class DirectIntakeTransport implements EventTransport { + static final int MAX_RETRIES = 5; + private static final long INITIAL_RETRY_DELAY_MILLIS = 100; + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); + + private final URI intake; + private final String apiKey; + private final String traceId; + private final HttpClient httpClient; + private final CdnConfigurationSource.RetrySleeper retrySleeper; + + DirectIntakeTransport( + final URI intake, + final String apiKey, + final HttpClient httpClient, + final CdnConfigurationSource.RetrySleeper retrySleeper) { + this.intake = intake; + this.apiKey = apiKey; + // Intake requests are attributed to a stable, random trace id. + this.traceId = Long.toUnsignedString(ThreadLocalRandom.current().nextLong() >>> 1); + this.httpClient = httpClient; + this.retrySleeper = retrySleeper; + } + + /** + * Creates a direct intake transport. + * + * @param settings the SDK settings. + * @return the transport, or {@code null} if no API key is configured. + */ + @Nullable + public static DirectIntakeTransport create(final Settings settings) { + final String apiKey = settings.apiKey(); + if (apiKey == null) { + return null; + } + return new DirectIntakeTransport( + URI.create("https://event-platform-intake." + settings.site() + "/api/v2/"), + apiKey, + HttpClient.newBuilder() + .connectTimeout(REQUEST_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(), + TimeUnit.MILLISECONDS::sleep); + } + + @Override + public void post(final String route, final byte[] json) throws IOException { + final HttpRequest.Builder builder = + HttpRequest.newBuilder(this.intake.resolve(route)) + .timeout(REQUEST_TIMEOUT) + .header("Content-Type", "application/json") + .header("dd-api-key", this.apiKey) + .header("x-datadog-trace-id", this.traceId) + .header("x-datadog-parent-id", this.traceId); + Headers.addMetadata(builder); + final HttpRequest request = builder.POST(HttpRequest.BodyPublishers.ofByteArray(json)).build(); + IOException failure = null; + long delayMillis = INITIAL_RETRY_DELAY_MILLIS; + for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + if (attempt > 0) { + this.retrySleeper.sleep(delayMillis); + delayMillis *= 2; + } + final int status = + this.httpClient.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); + if (status >= 200 && status < 300) { + return; + } + failure = new IOException("Request to " + route + " returned error response " + status); + if (!isRetryableStatus(status)) { + break; + } + } catch (final IOException e) { + failure = e; + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request to " + route + " was interrupted", e); + } + } + throw failure; + } + + static boolean isRetryableStatus(final int status) { + return status == 429 || status >= 500; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java new file mode 100644 index 00000000000..fe26bb6d31f --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java @@ -0,0 +1,39 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; +import com.datadog.openfeature.internal.connector.EventTransport; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Posts through the event proxy, switching to direct intake for good once the proxy is definitively + * unavailable. Ambiguous proxy failures are not replayed through direct intake. + */ +public final class FallbackTransport implements EventTransport { + private static final Logger LOGGER = LoggerFactory.getLogger(FallbackTransport.class); + + private final EventTransport proxy; + private final EventTransport direct; + private volatile boolean proxyUnavailable; + + public FallbackTransport(final EventTransport proxy, final EventTransport direct) { + this.proxy = proxy; + this.direct = direct; + } + + @Override + public void post(final String route, final byte[] json) throws IOException { + if (!this.proxyUnavailable) { + try { + this.proxy.post(route, json); + return; + } catch (final EventProxyUnavailableException e) { + LOGGER.debug( + "Switching Feature Flags event delivery from the event proxy to direct intake"); + this.proxyUnavailable = true; + } + } + this.direct.post(route, json); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Gzip.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Gzip.java new file mode 100644 index 00000000000..0525ebd397d --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Gzip.java @@ -0,0 +1,34 @@ +package com.datadog.openfeature.internal.delivery; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.http.HttpResponse; +import java.util.zip.GZIPInputStream; + +/** Gzip decoding, as the JDK HTTP client does not handle content encoding. */ +final class Gzip { + private Gzip() {} + + /** + * Decodes a response body according to its {@code Content-Encoding} header. + * + * @param response the response to decode the body of. + * @return the decoded body. + * @throws IOException if the body is not valid gzip content. + */ + static byte[] decode(final HttpResponse response) throws IOException { + final boolean gzipped = + response + .headers() + .firstValue("Content-Encoding") + .map("gzip"::equalsIgnoreCase) + .orElse(false); + if (!gzipped) { + return response.body(); + } + try (InputStream input = new GZIPInputStream(new ByteArrayInputStream(response.body()))) { + return input.readAllBytes(); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java new file mode 100644 index 00000000000..17cc71069a6 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java @@ -0,0 +1,28 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.config.SdkVersion; +import java.net.http.HttpRequest; + +/** Common Datadog request headers. */ +final class Headers { + private static final String JAVA_VERSION = System.getProperty("java.version", "unknown"); + private static final String JAVA_VM_NAME = System.getProperty("java.vm.name", "unknown"); + private static final String JAVA_VM_VENDOR = System.getProperty("java.vm.vendor", "unknown"); + + private Headers() {} + + /** + * Adds the language and SDK metadata headers. + * + * @param request the request to add the headers to. + */ + // TODO Add the Datadog-Container-ID and Datadog-Entity-ID headers once the SDK detects them. + static void addMetadata(final HttpRequest.Builder request) { + request + .header("Datadog-Meta-Lang", "java") + .header("Datadog-Meta-Lang-Version", JAVA_VERSION) + .header("Datadog-Meta-Lang-Interpreter", JAVA_VM_NAME) + .header("Datadog-Meta-Lang-Interpreter-Vendor", JAVA_VM_VENDOR) + .header("Datadog-Meta-Tracer-Version", SdkVersion.VERSION); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java new file mode 100644 index 00000000000..04c0722e9a8 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java @@ -0,0 +1,57 @@ +package com.datadog.openfeature.internal.delivery; + +import com.datadog.openfeature.internal.ConfigurationService; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import java.io.IOException; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Receives the flag configuration from Remote Configuration, through the Datadog Java agent. */ +public final class RemoteConfigurationService implements ConfigurationService { + private static final Logger LOGGER = LoggerFactory.getLogger(RemoteConfigurationService.class); + + private final ConfigurationSource source; + private AutoCloseable subscription; + + public RemoteConfigurationService(final ConfigurationSource source) { + this.source = source; + } + + @Override + public synchronized void start(final Consumer listener) { + if (this.subscription != null) { + return; + } + this.subscription = + this.source.subscribe( + content -> { + if (content == null) { + listener.accept(null); + return; + } + try { + final ServerConfiguration configuration = UniversalFlagConfigParser.parse(content); + if (configuration != null) { + listener.accept(configuration); + } + } catch (final IOException | RuntimeException e) { + LOGGER.debug("Remote Configuration returned a malformed flag configuration", e); + } + }); + } + + @Override + public synchronized void close() { + if (this.subscription != null) { + try { + this.subscription.close(); + } catch (final Exception e) { + LOGGER.debug("Failed to unsubscribe from Remote Configuration", e); + } + this.subscription = null; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java new file mode 100644 index 00000000000..391912dd226 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/ConfigurationStore.java @@ -0,0 +1,32 @@ +package com.datadog.openfeature.internal.evaluation; + +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Atomically publishes one validated snapshot for synchronous evaluation. */ +public final class ConfigurationStore { + private final AtomicReference current = new AtomicReference<>(); + private final CountDownLatch ready = new CountDownLatch(1); + + public ServerConfiguration get() { + return current.get(); + } + + public void set(ServerConfiguration configuration) { + current.set(configuration); + } + + public void markReady() { + ready.countDown(); + } + + public boolean wasReady() { + return ready.getCount() == 0; + } + + public boolean await(long timeout, TimeUnit unit) throws InterruptedException { + return ready.await(timeout, unit); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java new file mode 100644 index 00000000000..0f4112b2f3d --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java @@ -0,0 +1,10 @@ +package com.datadog.openfeature.internal.evaluation; + +/** Lazy application context; adapters retain their public SDK's value-conversion rules. */ +public interface EvaluationContext { + String getTargetingKey(); + + boolean hasAttribute(String name); + + Object attribute(String name); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java new file mode 100644 index 00000000000..b1b17d91501 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java @@ -0,0 +1,50 @@ +package com.datadog.openfeature.internal.evaluation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Immutable evaluation metadata, independent of the public OpenFeature SDK. */ +public final class EvaluationMetadata { + private final Map values; + + private EvaluationMetadata(final Map values) { + this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + public Map values() { + return values; + } + + public static EvaluationMetadataBuilder builder() { + return new EvaluationMetadataBuilder(); + } + + public static final class EvaluationMetadataBuilder { + private final Map values = new LinkedHashMap<>(); + + public EvaluationMetadataBuilder addString(String key, String value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addBoolean(String key, boolean value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addInteger(String key, int value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addLong(String key, long value) { + values.put(key, value); + return this; + } + + public EvaluationMetadata build() { + return new EvaluationMetadata(values); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java new file mode 100644 index 00000000000..e83edfd9355 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java @@ -0,0 +1,137 @@ +package com.datadog.openfeature.internal.evaluation; + +/** A resolved value plus product metadata. Transport and SDK adapters consume this result. */ +public final class EvaluationResult { + public enum ErrorCode { + PROVIDER_NOT_READY, + INVALID_CONTEXT, + FLAG_NOT_FOUND, + TARGETING_KEY_MISSING, + TYPE_MISMATCH, + PARSE_ERROR, + GENERAL + } + + public enum Reason { + DISABLED, + DEFAULT, + ERROR, + TARGETING_MATCH, + SPLIT, + STATIC + } + + private final T value; + private final String reason; + private final String variant; + private final ErrorCode errorCode; + private final String errorMessage; + private final EvaluationMetadata metadata; + private final boolean logExposure; + private final Integer serialId; + + private EvaluationResult(final Builder builder) { + value = builder.value; + reason = builder.reason; + variant = builder.variant; + errorCode = builder.errorCode; + errorMessage = builder.errorMessage; + metadata = builder.metadata; + logExposure = builder.logExposure; + serialId = builder.serialId; + } + + public T getValue() { + return value; + } + + public String getReason() { + return reason; + } + + public String getVariant() { + return variant; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public EvaluationMetadata getFlagMetadata() { + return metadata; + } + + public boolean isLogExposure() { + return logExposure; + } + + /** + * @return the serial id of the resolved split, or {@code null} if absent. + */ + public Integer getSerialId() { + return serialId; + } + + public static Builder builder() { + return new Builder<>(); + } + + public static final class Builder { + private T value; + private String reason; + private String variant; + private ErrorCode errorCode; + private String errorMessage; + private EvaluationMetadata metadata; + private boolean logExposure; + private Integer serialId; + + public Builder value(T value) { + this.value = value; + return this; + } + + public Builder reason(String reason) { + this.reason = reason; + return this; + } + + public Builder variant(String variant) { + this.variant = variant; + return this; + } + + public Builder errorCode(ErrorCode errorCode) { + this.errorCode = errorCode; + return this; + } + + public Builder errorMessage(String errorMessage) { + this.errorMessage = errorMessage; + return this; + } + + public Builder flagMetadata(EvaluationMetadata metadata) { + this.metadata = metadata; + return this; + } + + public Builder serialId(Integer serialId) { + this.serialId = serialId; + return this; + } + + public Builder logExposure(boolean logExposure) { + this.logExposure = logExposure; + return this; + } + + public EvaluationResult build() { + return new EvaluationResult<>(this); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java new file mode 100644 index 00000000000..ac0c16675b5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -0,0 +1,547 @@ +package com.datadog.openfeature.internal.evaluation; + +import static java.util.Arrays.asList; + +import com.datadog.openfeature.internal.evaluation.EvaluationResult.ErrorCode; +import com.datadog.openfeature.internal.evaluation.EvaluationResult.Reason; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ConditionConfiguration; +import com.datadog.openfeature.internal.ufc.ConditionOperator; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ParsedSemver; +import com.datadog.openfeature.internal.ufc.Rule; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Shard; +import com.datadog.openfeature.internal.ufc.ShardRange; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Local evaluation over a single configuration snapshot. No transport or OpenFeature dependency. + */ +public final class FlagEvaluator { + private static final Set> SUPPORTED_RESOLUTION_TYPES = + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Object.class)); + private static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = + "observe_full_evaluation_data"; + private static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; + private static final String METADATA_DO_LOG = "__dd_do_log"; + private final boolean spanEnrichmentEnabled; + + public FlagEvaluator(final boolean spanEnrichmentEnabled) { + this.spanEnrichmentEnabled = spanEnrichmentEnabled; + } + + public EvaluationResult evaluate( + final ServerConfiguration config, + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + // Snapshot the config once and thread observeFullEvaluationData through every + // EvaluationResult returned, so the hook's consent decision is pinned to this evaluation's + // config and cannot drift on a concurrent Remote Config swap. + // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without + // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. + final boolean observeFullEvaluationData = + config != null && Boolean.TRUE.equals(config.observeFullEvaluationData); + try { + if (config == null) { + return error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData); + } + + if (context == null) { + return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); + } + + final Flag flag = config.flags.get(key); + if (flag == null) { + if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "invalid configuration for flag " + key, + observeFullEvaluationData); + } + return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); + } + + if (!flag.enabled) { + return EvaluationResult.builder() + .value(defaultValue) + .reason(Reason.DISABLED.name()) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } + + if (flag.allocations == null) { + return error( + defaultValue, + ErrorCode.GENERAL, + "Missing allocations for flag " + key, + observeFullEvaluationData); + } + + final Instant now = Instant.now(); + final long evalTimestampMs = now.toEpochMilli(); + final String targetingKey = context.getTargetingKey(); + + for (final Allocation allocation : flag.allocations) { + if (!isAllocationActive(allocation, now)) { + continue; + } + + if (!isEmpty(allocation.rules)) { + if (!evaluateRules(allocation.rules, context)) { + continue; + } + } + + if (!isEmpty(allocation.splits)) { + for (final Split split : allocation.splits) { + if (isEmpty(split.shards)) { + return resolveVariant( + target, + key, + defaultValue, + flag, + split.variationKey, + allocation, + split, + context, + evalTimestampMs, + observeFullEvaluationData); + } else { + if (targetingKey == null) { + return error( + defaultValue, ErrorCode.TARGETING_KEY_MISSING, null, observeFullEvaluationData); + } + // To match a split, subject must match ALL underlying shards + boolean allShardsMatch = true; + for (final Shard shard : split.shards) { + if (!matchesShard(shard, targetingKey)) { + allShardsMatch = false; + break; + } + } + if (allShardsMatch) { + return resolveVariant( + target, + key, + defaultValue, + flag, + split.variationKey, + allocation, + split, + context, + evalTimestampMs, + observeFullEvaluationData); + } + } + } + } + } + + return EvaluationResult.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } catch (final PatternSyntaxException e) { + return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); + } catch (final NumberFormatException e) { + return error( + defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); + } catch (final Exception e) { + return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); + } + } + + private static EvaluationMetadata consentMetadata(final boolean observeFullEvaluationData) { + return EvaluationMetadata.builder() + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build(); + } + + private static EvaluationResult error( + final T defaultValue, + final ErrorCode code, + final String errorMessage, + final boolean observeFullEvaluationData) { + // Under consent-off the errorMessage is dropped: exception messages from the outer catch blocks + // (NumberFormatException, generic Exception) can echo raw evaluation-context values, so they + // must never reach any consumer of EvaluationResult.getErrorMessage() — not just our own + // wire hook. Downstream (FlagEvalLoggingHook) falls back to ErrorCode.name(), so operators + // still get a stable signal like "TYPE_MISMATCH". + return EvaluationResult.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(code) + .errorMessage(observeFullEvaluationData ? errorMessage : null) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } + + private static boolean isEmpty(final List list) { + return list == null || list.isEmpty(); + } + + public static boolean isAllocationActive(final Allocation allocation, final Instant now) { + final Instant startDate = allocation.startAt; + if (startDate != null && now.isBefore(startDate)) { + return false; + } + + final Instant endDate = allocation.endAt; + if (endDate != null && now.isAfter(endDate)) { + return false; + } + + return true; + } + + private static boolean evaluateRules(final List rules, final EvaluationContext context) { + for (final Rule rule : rules) { + if (isEmpty(rule.conditions)) { + continue; + } + + boolean allConditionsMatch = true; + for (final ConditionConfiguration condition : rule.conditions) { + if (!evaluateCondition(condition, context)) { + allConditionsMatch = false; + break; + } + } + + if (allConditionsMatch) { + return true; + } + } + return false; + } + + private static boolean evaluateCondition( + final ConditionConfiguration condition, final EvaluationContext context) { + if (condition.operator == ConditionOperator.IS_NULL) { + final Object value = resolveAttribute(condition.attribute, context); + boolean isNull = value == null; + // condition.value determines if we're checking for null (true) or not null (false) + boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true; + return isNull == expectedNull; + } + + final Object attributeValue = resolveAttribute(condition.attribute, context); + if (attributeValue == null) { + return false; + } + + switch (condition.operator) { + case MATCHES: + return matchesRegex(attributeValue, condition.value); + case NOT_MATCHES: + return !matchesRegex(attributeValue, condition.value); + case ONE_OF: + return isOneOf(attributeValue, condition.value); + case NOT_ONE_OF: + return !isOneOf(attributeValue, condition.value); + case GTE: + return compareNumber(attributeValue, condition.value, (a, b) -> a >= b); + case GT: + return compareNumber(attributeValue, condition.value, (a, b) -> a > b); + case LTE: + return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); + case LT: + return compareNumber(attributeValue, condition.value, (a, b) -> a < b); + case SEMVER_EQ: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o == 0); + case SEMVER_NEQ: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o != 0); + case SEMVER_LT: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o < 0); + case SEMVER_LTE: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o <= 0); + case SEMVER_GT: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o > 0); + case SEMVER_GTE: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o >= 0); + default: + return false; + } + } + + private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { + // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), + // which maps it to ErrorCode.PARSE_ERROR. + final Pattern pattern = Pattern.compile(normalizeRegex(String.valueOf(conditionValue))); + return pattern.matcher(String.valueOf(attributeValue)).find(); + } + + private static String normalizeRegex(final String regex) { + return regex + .replace("[:alnum:]", "\\p{Alnum}") + .replace("[:alpha:]", "\\p{Alpha}") + .replace("[:digit:]", "\\p{Digit}") + .replace("[:lower:]", "\\p{Lower}") + .replace("[:upper:]", "\\p{Upper}") + .replace("[:space:]", "\\p{Space}"); + } + + private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { + if (!(conditionValue instanceof Iterable)) { + return false; + } + for (final Object value : (Iterable) conditionValue) { + if (valuesEqual(attributeValue, value)) { + return true; + } + } + return false; + } + + private static boolean valuesEqual(final Object a, final Object b) { + if (Objects.equals(a, b)) { + return true; + } + + if (a instanceof Number || b instanceof Number) { + return compareNumber(a, b, (first, second) -> first == second); + } + + return String.valueOf(a).equals(String.valueOf(b)); + } + + private static boolean compareNumber( + final Object attributeValue, final Object conditionValue, NumberComparator comparator) { + final double a = mapValue(Double.class, attributeValue); + final double b = mapValue(Double.class, conditionValue); + return comparator.compare(a, b); + } + + /** + * Evaluates a semantic version comparison operator. The attribute value must be a string that is + * a valid semantic version, and the comparand must have been pre-parsed during configuration + * validation. If either is missing or invalid, the condition does not match. + */ + private static boolean evaluateSemverCondition( + final Object attributeValue, + final ParsedSemver comparand, + final SemverComparator comparator) { + if (!(attributeValue instanceof String) || comparand == null) { + return false; + } + final ParsedSemver parsedAttribute = ParsedSemver.parse((String) attributeValue); + if (parsedAttribute == null) { + return false; + } + return comparator.compare(ParsedSemver.compare(parsedAttribute, comparand)); + } + + private static boolean matchesShard(final Shard shard, final String targetingKey) { + final long assignedShard = getShard(shard.salt, targetingKey, shard.totalShards); + for (final ShardRange range : shard.ranges) { + if (assignedShard >= range.start && assignedShard < range.end) { + return true; + } + } + return false; + } + + private static long getShard( + final String salt, final String targetingKey, final long totalShards) { + final String hashKey = salt + "-" + targetingKey; + final String md5Hash = getMD5Hash(hashKey); + final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length())); + final long intFromHash = Long.parseLong(first8Chars, 16); + return intFromHash % totalShards; + } + + private static String getMD5Hash(final String input) { + try { + final MessageDigest md = MessageDigest.getInstance("MD5"); + final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + final String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 algorithm not available", e); + } + } + + private EvaluationResult resolveVariant( + final Class target, + final String key, + final T defaultValue, + final Flag flag, + final String variationKey, + final Allocation allocation, + final Split split, + final EvaluationContext context, + final long evalTimestampMs, + final boolean observeFullEvaluationData) { + final Variant variant = flag.variations.get(variationKey); + if (variant == null) { + return error( + defaultValue, + ErrorCode.GENERAL, + "Variant not found for: " + variationKey, + observeFullEvaluationData); + } + + if (!isTypeCompatible(target, flag.variationType)) { + return error( + defaultValue, + ErrorCode.TYPE_MISMATCH, + "Requested type " + + target.getSimpleName() + + " does not match flag variationType " + + flag.variationType.name(), + observeFullEvaluationData); + } + + final T mappedValue; + try { + mappedValue = mapValue(target, variant.value); + } catch (final NumberFormatException e) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "Variant '" + + variant.key + + "' value does not match declared type " + + flag.variationType.name() + + ": " + + e.getMessage(), + observeFullEvaluationData); + } + + // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, + // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". + final EvaluationMetadata.EvaluationMetadataBuilder metadataBuilder = + EvaluationMetadata.builder() + .addString("flagKey", flag.key) + .addString("variationType", flag.variationType.name()) + .addString("allocationKey", allocation.key) + .addLong("__dd_eval_timestamp_ms", evalTimestampMs) + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); + // 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 (spanEnrichmentEnabled) { + if (split.serialId != null) { + metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); + } + metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); + } + final EvaluationResult result = + EvaluationResult.builder() + .value(mappedValue) + .reason( + !isEmpty(allocation.rules) + ? Reason.TARGETING_MATCH.name() + : allocation.startAt != null || allocation.endAt != null + ? Reason.DEFAULT.name() + : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) + .variant(variant.key) + .flagMetadata(metadataBuilder.build()) + .logExposure(Boolean.TRUE.equals(allocation.doLog)) + .serialId(split.serialId) + .build(); + return result; + } + + private static Object resolveAttribute(final String name, final EvaluationContext context) { + // Special handling for "id" attribute: if not explicitly provided, use targeting key + if ("id".equals(name) && !context.hasAttribute(name)) { + return context.getTargetingKey(); + } + return context.attribute(name); + } + + public static boolean isTypeCompatible(final Class target, final ValueType variationType) { + if (variationType == null) { + return true; // No type info — allow any + } + switch (variationType) { + case BOOLEAN: + return target == Boolean.class; + case STRING: + return target == String.class; + case INTEGER: + return target == Integer.class; + case NUMERIC: + return target == Double.class; + case JSON: + return target == Object.class; + default: + return true; // Unknown types pass through — mapValue errors caught as GENERAL + } + } + + @SuppressWarnings("unchecked") + public static T mapValue(final Class target, final Object value) { + if (value == null) { + return null; + } + if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { + throw new IllegalArgumentException("Type not supported: " + target); + } + if (target.isInstance(value)) { + return target.cast(value); + } + if (target == String.class) { + return (T) String.valueOf(value); + } + if (target == Boolean.class) { + if (value instanceof Number) { + return (T) (Boolean) (parseDouble(value) != 0); + } + return (T) Boolean.valueOf(value.toString()); + } + if (target == Integer.class) { + final Double number = parseDouble(value); + return (T) (Integer) number.intValue(); + } + if (target == Double.class) { + final Double number = parseDouble(value); + return (T) number; + } + return (T) value; + } + + private static Double parseDouble(final Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return Double.parseDouble(String.valueOf(value)); + } + + @FunctionalInterface + private interface NumberComparator { + boolean compare(double a, double b); + } + + @FunctionalInterface + private interface SemverComparator { + boolean compare(int ordering); + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java similarity index 70% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java index 53df4b4b512..a4d40e9e3c3 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Allocation.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Allocation.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; public class Allocation { public final String key; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java similarity index 94% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java index 7125d009a69..0ce6e6c31d9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureCache.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java @@ -1,6 +1,5 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.exposure; -import datadog.trace.api.featureflag.exposure.ExposureEvent; import java.util.Objects; public interface ExposureCache { diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java similarity index 70% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java index 4101b767760..a49811908ba 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposureEvent.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; public class ExposureEvent { // milliseconds since epoch as given by System.currentTimeMillis() @@ -7,18 +7,8 @@ public class ExposureEvent { public final Flag flag; public final Variant variant; public final Subject subject; - public final Integer serial_id; - public ExposureEvent( - final long timestamp, - final Allocation allocation, - final Flag flag, - final Variant variant, - final Subject subject) { - this(timestamp, allocation, flag, variant, subject, null); - } - public ExposureEvent( final long timestamp, final Allocation allocation, diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java similarity index 51% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java index fcd50e5dc34..c0385845ce2 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java @@ -1,81 +1,48 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.telemetry.LogCollector.EXCLUDE_TELEMETRY; -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_EXPOSURE_PROCESSOR; -import static datadog.trace.util.AgentThreadFactory.newAgentThread; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.ExposuresRequest; -import datadog.trace.api.internal.VisibleForTesting; +package com.datadog.openfeature.internal.exposure; + +import com.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class ExposureWriterImpl implements ExposureWriter { +/** Deduplicates and batches exposure events to the event platform {@code exposures} route. */ +public class ExposurePipeline implements Consumer, AutoCloseable { - private static final Logger LOGGER = LoggerFactory.getLogger(ExposureWriterImpl.class); - private static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements - private static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; + private static final Logger LOGGER = LoggerFactory.getLogger(ExposurePipeline.class); + public static final int DEFAULT_CAPACITY = 1 << 16; // 65536 elements + public static final int DEFAULT_FLUSH_INTERVAL_IN_SECONDS = 1; private static final int FLUSH_THRESHOLD = 100; private static final String EXPOSURES_ROUTE = "exposures"; - private final MessagePassingBlockingQueue queue; + private final EventQueue queue; private final Thread serializerThread; - public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config config) { - this(DEFAULT_CAPACITY, DEFAULT_FLUSH_INTERVAL_IN_SECONDS, SECONDS, sco, config); - } - - ExposureWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final SharedCommunicationObjects sco, - final Config config) { - this( - capacity, - flushInterval, - timeUnit, - new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.EXPOSURE), - config); - } - - ExposureWriterImpl( + public ExposurePipeline( final int capacity, final long flushInterval, final TimeUnit timeUnit, - final FeatureFlagBackendApiFactory backendApiFactory, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this.queue = new EventQueue<>(capacity); final ExposureSerializingHandler serializer = - new ExposureSerializingHandler( - backendApiFactory, - queue, - flushInterval, - timeUnit, - FeatureFlagEvpContext.from(config), - this::close); - this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); + new ExposureSerializingHandler(transport, queue, flushInterval, timeUnit, context); + this.serializerThread = services.newThread("exposures", serializer); } - @Override - public void init() { - FeatureFlaggingGateway.addExposureListener(this); + /** Starts the background serializing thread. */ + public void start() { this.serializerThread.start(); } @Override public void close() { - FeatureFlaggingGateway.removeExposureListener(this); if (this.serializerThread.isAlive()) { this.serializerThread.interrupt(); } @@ -86,56 +53,44 @@ public void accept(final ExposureEvent event) { queue.offer(event); } - @VisibleForTesting boolean isSerializerThreadAlive() { return serializerThread.isAlive(); } - @VisibleForTesting int queueSize() { return queue.size(); } private static class ExposureSerializingHandler implements Runnable { - private final MessagePassingBlockingQueue queue; + private final EventQueue queue; private final long ticksRequiredToFlush; private long lastTicks; - private final FeatureFlagEvpPublisher evpPublisher; + private final EventTransport transport; private final Map context; private final ExposureCache cache; private final List buffer = new ArrayList<>(); - private final Runnable errorCallback; ExposureSerializingHandler( - final FeatureFlagBackendApiFactory backendApiFactory, - final MessagePassingBlockingQueue queue, + final EventTransport transport, + final EventQueue queue, final long flushInterval, final TimeUnit timeUnit, - final Map context, - final Runnable errorCallback) { + final Map context) { this.queue = queue; this.cache = new LRUExposureCache(queue.capacity()); - this.evpPublisher = - new FeatureFlagEvpPublisher<>(backendApiFactory::create, ExposuresRequest.class); + this.transport = transport; this.context = context; this.lastTicks = System.nanoTime(); this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); - this.errorCallback = errorCallback; - LOGGER.debug("starting exposure serializer"); } @Override public void run() { - if (!evpPublisher.start()) { - errorCallback.run(); - LOGGER.warn("Feature Flagging exposure delivery is disabled"); - return; - } try { runDutyCycle(); } catch (InterruptedException e) { @@ -179,19 +134,18 @@ protected void flushIfNecessary() { final byte[] payload; try { final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); - payload = evpPublisher.serialize(exposures); + payload = exposures.serialize(); } catch (RuntimeException e) { - LOGGER.error(EXCLUDE_TELEMETRY, "Could not serialize exposures; dropping batch", e); + LOGGER.error("Could not serialize exposures; dropping batch", e); this.buffer.clear(); return; } try { - evpPublisher.post(EXPOSURES_ROUTE, payload); + transport.post(EXPOSURES_ROUTE, payload); } catch (Exception e) { LOGGER.debug("Could not submit exposures", e); } finally { - // Best-effort delivery must not retry an ambiguously accepted batch. A later definitive - // proxy rejection could otherwise replay the same exposures through direct intake. + // Best-effort delivery must not retry an ambiguously accepted batch. this.buffer.clear(); } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java new file mode 100644 index 00000000000..c1522dc210e --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java @@ -0,0 +1,53 @@ +package com.datadog.openfeature.internal.exposure; + +import static com.datadog.openfeature.internal.JsonWriting.writeKeyObject; +import static com.datadog.openfeature.internal.JsonWriting.writeValue; + +import com.datadog.openfeature.internal.JsonWriting; +import java.util.List; +import java.util.Map; + +public class ExposuresRequest { + public final Map context; + public final List exposures; + + public ExposuresRequest(final Map context, final List exposures) { + this.context = context; + this.exposures = exposures; + } + + /** + * @return the UTF-8 JSON payload. + */ + public byte[] serialize() { + return JsonWriting.write( + generator -> { + generator.writeStartObject(); + generator.writeFieldName("context"); + writeValue(generator, this.context); + generator.writeArrayFieldStart("exposures"); + for (final ExposureEvent exposure : this.exposures) { + generator.writeStartObject(); + generator.writeNumberField("timestamp", exposure.timestamp); + writeKeyObject(generator, "allocation", exposure.allocation.key); + writeKeyObject(generator, "flag", exposure.flag.key); + writeKeyObject(generator, "variant", exposure.variant.key); + generator.writeObjectFieldStart("subject"); + if (exposure.subject.id != null) { + generator.writeStringField("id", exposure.subject.id); + } + if (exposure.subject.attributes != null) { + generator.writeFieldName("attributes"); + writeValue(generator, exposure.subject.attributes); + } + generator.writeEndObject(); + if (exposure.serial_id != null) { + generator.writeNumberField("serial_id", exposure.serial_id); + } + generator.writeEndObject(); + } + generator.writeEndArray(); + generator.writeEndObject(); + }); + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java similarity index 68% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java index c13b123ad6c..0085cfe4d8a 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Flag.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Flag.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; public class Flag { public final String key; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java similarity index 70% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java index e21752cc529..8a83e6df745 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/LRUExposureCache.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java @@ -1,7 +1,6 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.exposure; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.core.util.LRUCache; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -13,7 +12,13 @@ public class LRUExposureCache implements ExposureCache { private final Map cache; public LRUExposureCache(final int capacity) { - cache = new LRUCache<>(capacity); + cache = + new LinkedHashMap(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return super.size() > capacity; + } + }; } @Override diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java similarity index 83% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java index ebd381e7069..2ba7cfab153 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Subject.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Subject.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; import java.util.Map; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java similarity index 69% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java index dfc4cbe38c7..ce5ddb2ae01 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/Variant.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/Variant.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.exposure; +package com.datadog.openfeature.internal.exposure; public class Variant { public final String key; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java similarity index 98% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java index f5f44b94d7b..f31ea9a3da1 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEvent.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.flagevaluation; +package com.datadog.openfeature.internal.flagevaluation; import java.util.Collections; import java.util.Map; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java similarity index 96% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java index 5d0eeaa0e82..ed35250b7b9 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationAggregator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java @@ -1,9 +1,5 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; -import static datadog.trace.util.HashingUtils.addToHash; -import static datadog.trace.util.HashingUtils.hash; - -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -12,6 +8,19 @@ import java.util.concurrent.atomic.AtomicLong; final class FlagEvaluationAggregator { + private static int hash(Object first, Object second, Object third) { + return 31 * 31 * java.util.Objects.hashCode(first) + + 31 * java.util.Objects.hashCode(second) + + java.util.Objects.hashCode(third); + } + + private static int addToHash(int hash, Object value) { + return 31 * hash + java.util.Objects.hashCode(value); + } + + private static int addToHash(int hash, boolean value) { + return 31 * hash + Boolean.hashCode(value); + } // Design assumptions — document the scale we sized for static final int EXPECTED_FLAG_COUNT = 2_500; diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java similarity index 69% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java index 3a8734fd516..90ced6c3ac4 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationPayloads.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java @@ -1,50 +1,44 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; +import static com.datadog.openfeature.internal.JsonWriting.writeKeyObject; +import static com.datadog.openfeature.internal.JsonWriting.writeValue; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.datadog.openfeature.internal.JsonWriting; import java.io.ByteArrayOutputStream; -import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.Map; final class FlagEvaluationPayloads { - private static final byte[] PAYLOAD_SUFFIX = FeatureFlagEvpPublisher.utf8Bytes("]}"); - private static final byte[] JSON_COMMA = FeatureFlagEvpPublisher.utf8Bytes(","); + private static final byte[] PAYLOAD_SUFFIX = "]}".getBytes(UTF_8); + private static final byte[] JSON_COMMA = ",".getBytes(UTF_8); /** * Wire prefix identifying a privacy-preserving, hashed targeting key. Emitted for full-tier rows * when {@code observeFullEvaluationData} is off. The suffix is the lower-case hex SHA-256 of the - * UTF-8 targeting key (see {@link ULeb128Encoder#hashTargetingKey}). This is a cross-SDK wire - * contract - keep it in sync with the other server SDKs and the UFC/EVP spec. + * UTF-8 targeting key (see {@link #hashTargetingKey}). This is a cross-SDK wire contract - keep + * it in sync with the other server SDKs and the UFC/EVP spec. */ private static final String HASHED_TARGETING_KEY_PREFIX = "sha256_"; - private static final JsonAdapter EVENT_JSON_ADAPTER; - private static final JsonAdapter> CONTEXT_JSON_ADAPTER; - - static { - final Moshi moshi = new Moshi.Builder().build(); - EVENT_JSON_ADAPTER = moshi.adapter(FlagEvaluationEvent.class); - final Type contextType = Types.newParameterizedType(Map.class, String.class, String.class); - CONTEXT_JSON_ADAPTER = moshi.adapter(contextType); - } + // Per-thread SHA-256 instance as hashing runs for every full-tier row without consent. + private static final ThreadLocal SHA_256 = + ThreadLocal.withInitial( + () -> { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + }); private FlagEvaluationPayloads() {} - static class FlagEvaluationsRequest { - public final Map context; - public final List flagEvaluations; - - FlagEvaluationsRequest( - final Map context, final List flagEvaluations) { - this.context = context; - this.flagEvaluations = flagEvaluations; - } - } - static EncodedPayloads buildPayloads( final List events, final Map context, @@ -92,12 +86,62 @@ static EncodedPayloads buildPayloads( } private static byte[] payloadPrefix(final Map context) { - return FeatureFlagEvpPublisher.utf8Bytes( - "{\"context\":" + CONTEXT_JSON_ADAPTER.toJson(context) + ",\"flagEvaluations\":["); + final byte[] contextJson = JsonWriting.write(generator -> writeValue(generator, context)); + return ("{\"context\":" + new String(contextJson, UTF_8) + ",\"flagEvaluations\":[") + .getBytes(UTF_8); } - private static byte[] encodeEvent(final FlagEvaluationEvent event) { - return FeatureFlagEvpPublisher.utf8Bytes(EVENT_JSON_ADAPTER.toJson(event)); + static byte[] encodeEvent(final FlagEvaluationEvent event) { + return JsonWriting.write( + generator -> { + generator.writeStartObject(); + generator.writeNumberField("timestamp", event.timestamp); + writeKeyObject(generator, "flag", event.flag.key); + generator.writeNumberField("first_evaluation", event.first_evaluation); + generator.writeNumberField("last_evaluation", event.last_evaluation); + generator.writeNumberField("evaluation_count", event.evaluation_count); + writeKeyObject(generator, "variant", keyOf(event.variant)); + writeKeyObject(generator, "allocation", keyOf(event.allocation)); + if (event.targeting_key != null) { + generator.writeStringField("targeting_key", event.targeting_key); + } + if (event.runtime_default_used != null) { + generator.writeBooleanField("runtime_default_used", event.runtime_default_used); + } + if (event.context != null) { + generator.writeObjectFieldStart("context"); + generator.writeFieldName("evaluation"); + writeValue(generator, event.context.evaluation); + generator.writeEndObject(); + } + if (event.error != null) { + generator.writeObjectFieldStart("error"); + generator.writeStringField("message", event.error.message); + generator.writeEndObject(); + } + generator.writeEndObject(); + }); + } + + /** + * Hashes a targeting key. + * + * @param value the targeting key. + * @return the lower-case hex SHA-256 of the UTF-8 targeting key. + */ + 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(); } static final class EncodedPayloads { @@ -230,7 +274,7 @@ private static String resolveTargetingKey( if (observeFullEvaluationData) { return rawTargetingKey; } - return HASHED_TARGETING_KEY_PREFIX + ULeb128Encoder.hashTargetingKey(rawTargetingKey); + return HASHED_TARGETING_KEY_PREFIX + hashTargetingKey(rawTargetingKey); } FlagEvaluationEvent withoutTargetingKeyAndContext() { diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java similarity index 74% rename from products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java index 2da8ffe0256..50fb86dbd29 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FlagEvaluationWriterImpl.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java @@ -1,19 +1,8 @@ -package com.datadog.featureflag; - -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_EVALUATION_PROCESSOR; -import static datadog.trace.util.AgentThreadFactory.newAgentThread; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -import datadog.communication.BackendApi; -import datadog.communication.EvpProxy; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.telemetry.CoreMetricCollector; +package com.datadog.openfeature.internal.flagevaluation; + +import com.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.List; @@ -23,16 +12,14 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; -import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * EVP flagevaluation writer for Java. * - *

    Uses the same EVP publisher path as ExposureWriterImpl, with two-tier aggregation replacing - * the single-exposure buffer. Uses a local EVP proxy when available. Agentless mode can use direct - * intake when no compatible local route is available. + *

    Uses the same event transport as the exposure pipeline, with two-tier aggregation replacing + * the single-exposure buffer. * *

    Two-tier aggregation contract: Full key: (flagKey, variant, allocationKey, runtimeDefault, * errorMessage, targetingKey, canonical-context-key). Degraded key: (flagKey, variant, @@ -42,23 +29,23 @@ * <=256 chars; the pruned attributes are what gets aggregated and serialized. Caps: * globalCap=131072, perFlagCap=10000, degradedCap=32768. Eval-time: min/max of * firstEvalMs/lastEvalMs across events in the same bucket. Runtime default: absent variant means - * runtimeDefaultUsed=true. Flush interval: 10 seconds. Queue: bounded MessagePassingBlockingQueue - * (capacity 2^16), non-blocking offer; on overflow the event is dropped and the - * droppedQueueOverflow counter is incremented and surfaced on flush. Enqueue: lock-free. Producers - * contend only on the MPSC queue, never on a monitor, so evaluation threads do not serialize - * against each other. Shutdown: close() drains the queue and performs a final flush before the - * worker thread exits. Because enqueue is lock-free, a producer can still offer during shutdown; - * close() sweeps the queue once the worker has been joined, counting any remainder as a closed drop - * so shutdown loss is observable rather than silent. + * runtimeDefaultUsed=true. Flush interval: 10 seconds. Queue: bounded lock-free queue (capacity + * 2^12), non-blocking offer; on overflow the event is dropped and the droppedQueueOverflow counter + * is incremented and surfaced on flush. Enqueue: lock-free. Producers contend only on the MPSC + * queue, never on a monitor, so evaluation threads do not serialize against each other. Shutdown: + * close() drains the queue and performs a final flush before the worker thread exits. Because + * enqueue is lock-free, a producer can still offer during shutdown; close() sweeps the queue once + * the worker has been joined, counting any remainder as a closed drop so shutdown loss is + * observable rather than silent. */ -public class FlagEvaluationWriterImpl implements FlagEvaluationWriter { +public class FlagEvaluationPipeline implements AutoCloseable { - private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationWriterImpl.class); + private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationPipeline.class); - static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC - static final int FLUSH_INTERVAL_SECONDS = 10; + public static final int DEFAULT_CAPACITY = 1 << 12; // 4096 elements, per cross-SDK RFC + public static final int FLUSH_INTERVAL_SECONDS = 10; - static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = EvpProxy.PAYLOAD_SIZE_LIMIT_BYTES; + static final int FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; static final String FLAG_EVALUATION_DROPPED_METRIC = "flagevaluation.rows.dropped"; static final String FLAG_EVALUATION_DEGRADED_METRIC = "flagevaluation.rows.degraded"; static final String FLAG_EVALUATION_SPLITS_METRIC = "flagevaluation.payload.splits"; @@ -70,19 +57,16 @@ public class FlagEvaluationWriterImpl implements FlagEvaluationWriter { static final String DEGRADED_REASON_CARDINALITY_CAP = "cardinality_cap"; static final String DEGRADED_REASON_PAYLOAD_LIMIT = "payload_limit"; private static final String FLAG_EVALUATION_ROUTE = "flagevaluation"; - private static final CoreMetricCollector CORE_METRICS = CoreMetricCollector.getInstance(); + private final RuntimeServices services; - private final MessagePassingBlockingQueue queue; + private final EventQueue queue; private final FlagEvaluationSerializingHandler serializer; private final Thread serializerThread; private final Object lifecycleLock = new Object(); private final AtomicBoolean closed = new AtomicBoolean(false); - private static void countMetric(final String metricName, final long value, final String reason) { - if (value <= 0) { - return; - } - CORE_METRICS.count(metricName, value, reason == null ? null : "reason:" + reason); + private void countMetric(final String metricName, final long value, final String reason) { + services.countMetric(metricName, value, reason); } /** @@ -99,50 +83,31 @@ private static void countMetric(final String metricName, final long value, final private final ConcurrentHashMap contextTruncatedCounts = new ConcurrentHashMap<>(); - public FlagEvaluationWriterImpl(final SharedCommunicationObjects sco, final Config config) { - this( - DEFAULT_CAPACITY, - FLUSH_INTERVAL_SECONDS, - SECONDS, - new FeatureFlagBackendApiFactory(config, sco, FeatureFlagEventType.FLAG_EVALUATION)::create, - config); - } - - FlagEvaluationWriterImpl( + public FlagEvaluationPipeline( final int capacity, final long flushInterval, final TimeUnit timeUnit, - final Supplier backendApiSupplier, - final Config config) { - this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this.services = services; + this.queue = new EventQueue<>(capacity); this.serializer = new FlagEvaluationSerializingHandler( - backendApiSupplier, + transport, queue, flushInterval, timeUnit, - FeatureFlagEvpContext.from(config), + context, droppedQueueOverflow, contextTruncatedCounts, - this::close, - FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - this.serializerThread = newAgentThread(FEATURE_FLAG_EVALUATION_PROCESSOR, serializer); + FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, + services); + this.serializerThread = services.newThread("evaluations", serializer); } - @Override + /** Starts the background serializing thread. */ public void start() { - synchronized (lifecycleLock) { - if (closed.get()) { - return; - } - // Register with the gateway so FlagEvalLoggingHook can route evaluations to this writer - FeatureFlaggingGateway.setFlagEvalWriter(this); - this.serializerThread.start(); - } - } - - /** Test seam: starts the worker thread WITHOUT registering with the global gateway. */ - void startForTest() { synchronized (lifecycleLock) { if (closed.get()) { return; @@ -163,9 +128,6 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } - // Disable and deregister from the gateway so no new events are enqueued. - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - FeatureFlaggingGateway.setFlagEvalWriter(null); workerRunning = this.serializerThread.isAlive(); if (workerRunning) { // Ask the worker to drain the queue and final-flush, then interrupt to wake it from poll(). @@ -206,12 +168,17 @@ private void sweepAndCountResidualEvents() { countMetric(FLAG_EVALUATION_DROPPED_METRIC, residual, DROP_REASON_CLOSED); } - @Override + /** + * Non-blocking enqueue of a flag evaluation event. Drops the event, and counts the drop, if the + * queue is full. + * + * @param event the event to enqueue. + */ public void enqueue(final FlagEvalEvent event) { if (event == null) { return; } - if (isClosedOrEnqueueDisabled()) { + if (closed.get()) { countClosedDrop(); return; } @@ -249,20 +216,16 @@ public void countPreQueueOverflow() { droppedQueueOverflow.incrementAndGet(); } - @Override + /** + * Counts one evaluation whose context was truncated. + * + * @param reason the sorted, comma-separated set of the caps that fired. + */ public void countContextTruncated(final String reason) { contextTruncatedCounts.computeIfAbsent(reason, k -> new AtomicLong(0)).incrementAndGet(); } - private boolean isClosedOrEnqueueDisabled() { - return closed.get() || !FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled(); - } - private void countClosedDrop() { - // Count the drop for any early exit from enqueue, whether closed=true or the gate was disabled - // by the surrounding subsystem. FeatureFlaggingSystem.stop() flips the gate before this - // writer's close() runs, so an in-flight enqueue could race that flip and see gate=false while - // closed=false. Counting on either condition keeps shutdown-loss observable. countMetric(FLAG_EVALUATION_DROPPED_METRIC, 1, DROP_REASON_CLOSED); } @@ -284,7 +247,13 @@ void flushForTest() { // ---- Serializing handler (background thread logic) ---- static class FlagEvaluationSerializingHandler implements Runnable { - private final MessagePassingBlockingQueue queue; + private final RuntimeServices services; + + private void countMetric(String name, long value, String reason) { + services.countMetric(name, value, reason); + } + + private final EventQueue queue; private final long ticksRequiredToFlush; @SuppressFBWarnings( @@ -292,12 +261,10 @@ static class FlagEvaluationSerializingHandler implements Runnable { justification = "the field is confined to the single serializer thread") private long lastTicks; - private final FeatureFlagEvpPublisher - evpPublisher; + private final EventTransport transport; final Map context; private final AtomicLong droppedQueueOverflow; private final ConcurrentHashMap contextTruncatedCounts; - private final Runnable errorCallback; private final int payloadSizeLimitBytes; final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); @@ -306,26 +273,24 @@ static class FlagEvaluationSerializingHandler implements Runnable { private final CountDownLatch finalFlushDone = new CountDownLatch(1); FlagEvaluationSerializingHandler( - final Supplier backendApiSupplier, - final MessagePassingBlockingQueue queue, + final EventTransport transport, + final EventQueue queue, final long flushInterval, final TimeUnit timeUnit, final Map context, final AtomicLong droppedQueueOverflow, final ConcurrentHashMap contextTruncatedCounts, - final Runnable errorCallback, - final int payloadSizeLimitBytes) { + final int payloadSizeLimitBytes, + final RuntimeServices services) { + this.services = services; this.queue = queue; - this.evpPublisher = - new FeatureFlagEvpPublisher<>( - backendApiSupplier, FlagEvaluationPayloads.FlagEvaluationsRequest.class); + this.transport = transport; this.context = context; this.droppedQueueOverflow = droppedQueueOverflow; this.contextTruncatedCounts = contextTruncatedCounts; this.payloadSizeLimitBytes = payloadSizeLimitBytes; this.lastTicks = System.nanoTime(); this.ticksRequiredToFlush = timeUnit.toNanos(flushInterval); - this.errorCallback = errorCallback; LOGGER.debug("starting flag evaluation serializer"); } @@ -336,11 +301,6 @@ void requestShutdown() { @Override public void run() { - if (!evpPublisher.start()) { - finalFlushDone.countDown(); - errorCallback.run(); - throw new IllegalArgumentException("EVP Proxy not available"); - } try { runDutyCycle(); } catch (InterruptedException e) { @@ -350,7 +310,7 @@ public void run() { // queued events are not lost on shutdown. // // close() interrupts this thread to break it out of poll(). The final flush does socket - // I/O, and OkHttp fails fast on a thread whose interrupt flag is set, so clear the flag + // I/O, which fails fast on a thread whose interrupt flag is set, so clear the flag // for the duration of the drain and restore it afterwards. Without this the flush that // close() exists to guarantee throws IOException, the aggregated rows are discarded by // the clear() in flush(), and the loss is invisible to the drop counters because those @@ -466,7 +426,7 @@ void flush() { payloads.droppedPayloadLimit); } for (final byte[] payload : payloads.bodies) { - evpPublisher.post(FLAG_EVALUATION_ROUTE, payload); + transport.post(FLAG_EVALUATION_ROUTE, payload); } } catch (Exception e) { LOGGER.error("Could not submit flag evaluations", e); @@ -522,24 +482,27 @@ private boolean shouldFlush() { static class SerializingHandlerForTest extends FlagEvaluationSerializingHandler { SerializingHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - this(backendApiSupplier, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + final EventTransport transport, + final Map context, + final RuntimeServices services) { + this(transport, context, FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, services); } SerializingHandlerForTest( - final Supplier backendApiSupplier, + final EventTransport transport, final Map context, - final int payloadSizeLimitBytes) { + final int payloadSizeLimitBytes, + final RuntimeServices services) { super( - backendApiSupplier, - Queues.mpscBlockingConsumerArrayQueue(DEFAULT_CAPACITY), + transport, + new EventQueue<>(DEFAULT_CAPACITY), Long.MAX_VALUE, // effectively never auto-flush TimeUnit.NANOSECONDS, context, new AtomicLong(0), new ConcurrentHashMap<>(), - () -> {}, - payloadSizeLimitBytes); + payloadSizeLimitBytes, + services); } private final List staged = new ArrayList<>(); @@ -592,17 +555,4 @@ int fullTierSizeForTest() { return aggregator.fullTierSize(); } } - - /** Factory method for test use - creates a SerializingHandlerForTest. */ - static SerializingHandlerForTest createHandlerForTest( - final Supplier backendApiSupplier, final Map context) { - return new SerializingHandlerForTest(backendApiSupplier, context); - } - - static SerializingHandlerForTest createHandlerForTest( - final Supplier backendApiSupplier, - final Map context, - final int payloadSizeLimitBytes) { - return new SerializingHandlerForTest(backendApiSupplier, context, payloadSizeLimitBytes); - } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java new file mode 100644 index 00000000000..b37a676571a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java @@ -0,0 +1,28 @@ +package com.datadog.openfeature.internal.ufc; + +import java.time.Instant; +import java.util.List; + +public final class Allocation { + public final String key; + public final List rules; + public final Instant startAt; + public final Instant endAt; + public final List splits; + public final Boolean doLog; + + public Allocation( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog) { + this.key = key; + this.rules = rules; + this.startAt = startAt; + this.endAt = endAt; + this.splits = splits; + this.doLog = doLog; + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java similarity index 85% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java index ae693674acc..c5d79cd67e1 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionConfiguration.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java @@ -1,6 +1,6 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; -public class ConditionConfiguration { +public final class ConditionConfiguration { public final ConditionOperator operator; public final String attribute; public final Object value; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java similarity index 81% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java index 1e864da2b6a..fc3d3bab683 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ConditionOperator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; public enum ConditionOperator { LT, diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java similarity index 54% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java index 86d04c7d8ef..664ae3fa07e 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Environment.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java @@ -1,6 +1,6 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; -public class Environment { +public final class Environment { public final String name; public Environment(String name) { diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java similarity index 89% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java index 529e3145344..8ff4dd83989 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Flag.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java @@ -1,9 +1,9 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.List; import java.util.Map; -public class Flag { +public final class Flag { public final String key; public final boolean enabled; public final ValueType variationType; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java similarity index 99% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java index 03ef7590fa1..c4f99828516 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemver.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.Arrays; diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java similarity index 71% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java index ed0fe60f366..819ebeeaf23 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Rule.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java @@ -1,8 +1,8 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.List; -public class Rule { +public final class Rule { public final List conditions; public Rule(final List conditions) { diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java similarity index 63% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java index 60f39fc666f..96aa6288154 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ServerConfiguration.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java @@ -1,17 +1,15 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.Collections; import java.util.Map; -public class ServerConfiguration { +public final class ServerConfiguration { public final String createdAt; public final String format; - // Boxed on purpose. Moshi's reflective adapter for a primitive boolean field aborts the whole - // UFC parse when the JSON value is null or not a boolean; with a Boolean field it tolerates - // null (and other malformed values are still caught locally) so a malformed consent field - // doesn't strand a fresh pod on PROVIDER_NOT_READY. Read sites must use - // Boolean.TRUE.equals(...) so null falls to the privacy-preserving default. + + /** PII consent; {@code null} must be read as the privacy-preserving {@code false}. */ public final Boolean observeFullEvaluationData; + public final Environment environment; public final Map flags; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java new file mode 100644 index 00000000000..9f9faf0f483 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java @@ -0,0 +1,16 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; + +/** A UFC shard. {@code totalShards} holds an unsigned 32-bit wire value. */ +public final class Shard { + public final String salt; + public final List ranges; + public final long totalShards; + + public Shard(final String salt, final List ranges, final long totalShards) { + this.salt = salt; + this.ranges = ranges; + this.totalShards = totalShards; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java new file mode 100644 index 00000000000..f48c9bfac45 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java @@ -0,0 +1,12 @@ +package com.datadog.openfeature.internal.ufc; + +/** A UFC shard range. {@code start} and {@code end} hold unsigned 32-bit wire values. */ +public final class ShardRange { + public final long start; + public final long end; + + public ShardRange(final long start, final long end) { + this.start = start; + this.end = end; + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java similarity index 75% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java index 5e98f90dabc..86d39ee9720 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Split.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java @@ -1,15 +1,14 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import java.util.List; import java.util.Map; -public class Split { +public final 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. + + /** Absent in some UFC shapes. Surfaced as {@code __dd_split_serial_id} for span enrichment. */ public final Integer serialId; // Null when the UFC split has no features. A provider must check that this field exists before // reading it, because an older agent ships a Split without it. diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java new file mode 100644 index 00000000000..fb67e8c19ac --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -0,0 +1,568 @@ +package com.datadog.openfeature.internal.ufc; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import java.io.IOException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses Universal Flag Configuration (UFC) documents. + * + *

    The document is first read as a generic JSON tree (objects, arrays, strings, {@link Double} + * numbers, booleans and nulls), then mapped onto the UFC model. Malformed flags are dropped + * individually and reported through {@link ServerConfiguration#invalidFlags}; a malformed envelope + * fails the whole document. + */ +public final class UniversalFlagConfigParser { + private static final Logger LOGGER = LoggerFactory.getLogger(UniversalFlagConfigParser.class); + + static final String INVALID_FLAG = "invalid_flag"; + static final String INVALID_SEMVER_COMPARAND = "invalid_semver_comparand"; + private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + + private static final JsonFactory JSON_FACTORY = + new JsonFactory().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + + private UniversalFlagConfigParser() {} + + /** + * Parses a raw UFC document, as delivered by Remote Configuration. + * + * @param content the UFC JSON document. + * @return the parsed configuration, or {@code null} for a JSON {@code null} document. + * @throws IOException if the document is not valid JSON or does not match the UFC envelope. + */ + @Nullable + public static ServerConfiguration parse(final byte[] content) throws IOException { + return toServerConfiguration(readDocument(content)); + } + + /** + * Parses a JSON:API response wrapping a UFC document, as delivered by the CDN. + * + * @param content the JSON:API response body. + * @return the parsed configuration, or {@code null} if the response holds no UFC document. + * @throws IOException if the response is not valid JSON or does not match the UFC envelope. + */ + @Nullable + public static ServerConfiguration parseJsonApi(final byte[] content) throws IOException { + final Object response = readDocument(content); + if (!(response instanceof Map)) { + return null; + } + final Object data = ((Map) response).get("data"); + if (!(data instanceof Map)) { + return null; + } + final Map dataObject = (Map) data; + if (!UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(dataObject.get("type"))) { + return null; + } + final ServerConfiguration configuration = toServerConfiguration(dataObject.get("attributes")); + return configuration != null && configuration.flags != null ? configuration : null; + } + + private static Object readDocument(final byte[] content) throws IOException { + try (JsonParser parser = JSON_FACTORY.createParser(content)) { + if (parser.nextToken() == null) { + throw new JsonParseException(parser, "Empty document"); + } + final Object document = readValue(parser); + if (parser.nextToken() != null) { + throw new JsonParseException(parser, "Unexpected content after the document"); + } + return document; + } + } + + private static Object readValue(final JsonParser parser) throws IOException { + final JsonToken token = parser.currentToken(); + switch (token) { + case START_OBJECT: + final Map object = new LinkedHashMap<>(); + while (parser.nextToken() == JsonToken.FIELD_NAME) { + final String name = parser.currentName(); + parser.nextToken(); + object.put(name, readValue(parser)); + } + return object; + case START_ARRAY: + final List array = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + array.add(readValue(parser)); + } + return array; + case VALUE_STRING: + return parser.getText(); + case VALUE_NUMBER_INT: + case VALUE_NUMBER_FLOAT: + return parser.getDoubleValue(); + case VALUE_TRUE: + return Boolean.TRUE; + case VALUE_FALSE: + return Boolean.FALSE; + case VALUE_NULL: + return null; + default: + throw new JsonParseException(parser, "Unexpected token " + token); + } + } + + @Nullable + private static ServerConfiguration toServerConfiguration(@Nullable final Object value) + throws IOException { + if (value == null) { + return null; + } + try { + final Map json = asObject(value, "configuration"); + final Map invalidFlags = new HashMap<>(); + final ServerConfiguration configuration = + new ServerConfiguration( + asString(json.get("createdAt"), "createdAt"), + asString(json.get("format"), "format"), + lenientBoolean(json.get("observeFullEvaluationData")), + toEnvironment(json.get("environment")), + toFlags(json.get("flags"), invalidFlags)); + if (!invalidFlags.isEmpty()) { + configuration.invalidFlags = invalidFlags; + } + return configuration; + } catch (final IllegalArgumentException e) { + throw new IOException("Invalid flag configuration: " + e.getMessage(), e); + } + } + + @Nullable + private static Environment toEnvironment(@Nullable final Object value) { + if (value == null) { + return null; + } + return new Environment(asString(asObject(value, "environment").get("name"), "name")); + } + + @Nullable + private static Map toFlags( + @Nullable final Object value, final Map invalidFlags) { + if (value == null) { + return null; + } + final Map flags = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "flags").entrySet()) { + final String flagKey = (String) entry.getKey(); + try { + final Flag flag = toFlag(entry.getValue()); + if (flag != null) { + validateFlag(flagKey, flag); + flags.put(flagKey, flag); + } + } catch (final IllegalArgumentException error) { + invalidFlags.put( + flagKey, + error instanceof InvalidSemverComparandException + ? INVALID_SEMVER_COMPARAND + : INVALID_FLAG); + LOGGER.warn("Dropping malformed FFE flag {}: {}", flagKey, error.toString()); + } + } + return flags; + } + + @Nullable + private static Flag toFlag(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "flag"); + return new Flag( + asString(json.get("key"), "key"), + strictBoolean(json.get("enabled"), "enabled"), + asEnum(json.get("variationType"), ValueType.class, "variationType"), + toVariants(json.get("variations")), + toList(json.get("allocations"), "allocations", UniversalFlagConfigParser::toAllocation)); + } + + @Nullable + private static Map toVariants(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map variants = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "variations").entrySet()) { + variants.put((String) entry.getKey(), toVariant(entry.getValue())); + } + return variants; + } + + @Nullable + private static Variant toVariant(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "variant"); + return new Variant(asString(json.get("key"), "key"), json.get("value")); + } + + @Nullable + private static Allocation toAllocation(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "allocation"); + return new Allocation( + asString(json.get("key"), "key"), + toList(json.get("rules"), "rules", UniversalFlagConfigParser::toRule), + toInstant(json.get("startAt")), + toInstant(json.get("endAt")), + toList(json.get("splits"), "splits", UniversalFlagConfigParser::toSplit), + lenientBoolean(json.get("doLog"))); + } + + @Nullable + private static Rule toRule(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "rule"); + return new Rule( + toList(json.get("conditions"), "conditions", UniversalFlagConfigParser::toCondition)); + } + + @Nullable + private static ConditionConfiguration toCondition(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "condition"); + return new ConditionConfiguration( + asEnum(json.get("operator"), ConditionOperator.class, "operator"), + asString(json.get("attribute"), "attribute"), + json.get("value")); + } + + @Nullable + private static Split toSplit(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "split"); + final Object serialId = json.get("serialId"); + return new Split( + toList(json.get("shards"), "shards", UniversalFlagConfigParser::toShard), + asString(json.get("variationKey"), "variationKey"), + toStringMap(json.get("extraLogging")), + serialId == null ? null : asLenientInt(serialId, "serialId")); + } + + @Nullable + private static Shard toShard(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "shard"); + return new Shard( + asString(json.get("salt"), "salt"), + toList(json.get("ranges"), "ranges", UniversalFlagConfigParser::toShardRange), + asUnsignedInt(json.get("totalShards"), "totalShards")); + } + + @Nullable + private static ShardRange toShardRange(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "range"); + return new ShardRange( + asUnsignedInt(json.get("start"), "range start"), + asUnsignedInt(json.get("end"), "range end")); + } + + @Nullable + private static Map toStringMap(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map map = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "extraLogging").entrySet()) { + map.put((String) entry.getKey(), asString(entry.getValue(), (String) entry.getKey())); + } + return map; + } + + @Nullable + private static List toList( + @Nullable final Object value, final String name, final ElementMapper mapper) { + if (value == null) { + return null; + } + if (!(value instanceof List)) { + throw new IllegalArgumentException(name + " is not an array"); + } + final List json = (List) value; + final List list = new ArrayList<>(json.size()); + for (final Object element : json) { + list.add(mapper.map(element)); + } + return list; + } + + private static Map asObject(final Object value, final String name) { + if (!(value instanceof Map)) { + throw new IllegalArgumentException(name + " is not an object"); + } + return (Map) value; + } + + @Nullable + private static String asString(@Nullable final Object value, final String name) { + if (value == null || value instanceof String) { + return (String) value; + } + throw new IllegalArgumentException(name + " is not a string"); + } + + private static boolean strictBoolean(@Nullable final Object value, final String name) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + throw new IllegalArgumentException(name + " is not a boolean"); + } + + /** Maps wrong-typed values to {@code null} so callers fall back to their default. */ + @Nullable + private static Boolean lenientBoolean(@Nullable final Object value) { + return value instanceof Boolean ? (Boolean) value : null; + } + + @Nullable + private static > E asEnum( + @Nullable final Object value, final Class type, final String name) { + final String constant = asString(value, name); + if (constant == null) { + return null; + } + try { + return Enum.valueOf(type, constant); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException(name + " has an unknown value " + constant); + } + } + + /** + * Coerces a number, or a numeric string, to an int, truncating fractions and saturating out of + * range values. + */ + private static int asLenientInt(final Object value, final String name) { + if (value instanceof Double) { + return (int) (double) (Double) value; + } + if (value instanceof String) { + try { + return (int) Double.parseDouble((String) value); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException(name + " is not a number"); + } + } + throw new IllegalArgumentException(name + " is not a number"); + } + + private static long asUnsignedInt(@Nullable final Object value, final String name) { + if (value == null) { + return 0; + } + return asIntegral(value, 0, MAX_UNSIGNED_INT, name); + } + + private static long asIntegral( + final Object value, final long min, final long max, final String name) { + if (!(value instanceof Double)) { + throw new IllegalArgumentException(name + " is not a number"); + } + final double number = (Double) value; + if (number != Math.rint(number) || number < min || number > max) { + throw new IllegalArgumentException("flag contains an invalid " + name + " value"); + } + return (long) number; + } + + /** Ignores wrongly set dates. */ + @Nullable + private static Instant toInstant(@Nullable final Object value) { + if (!(value instanceof String)) { + return null; + } + try { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse((String) value, Instant::from); + } catch (final RuntimeException e) { + return null; + } + } + + /** Validates the required nested UFC fields and SemVer comparands for a flag. */ + private static void validateFlag(final String flagKey, final Flag flag) { + if (flag.allocations == null) { + return; + } + for (final Allocation allocation : flag.allocations) { + if (allocation == null) { + continue; + } + validateConditionOperands(flagKey, allocation); + if (allocation.splits == null) { + continue; + } + for (final Split split : allocation.splits) { + if (split == null) { + continue; + } + if (split.shards == null) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" contains a split with missing shards"); + } + for (final Shard shard : split.shards) { + if (shard == null || shard.totalShards == 0 || shard.ranges == null) { + throw new IllegalArgumentException("flag \"" + flagKey + "\" contains invalid shards"); + } + for (final ShardRange range : shard.ranges) { + if (range == null) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" contains an invalid shard range"); + } + } + } + } + } + validateAndCacheSemverComparands(flagKey, flag); + } + + private static void validateConditionOperands(final String flagKey, final Allocation allocation) { + if (allocation.rules == null) { + return; + } + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition == null || condition.operator == null) { + continue; + } + switch (condition.operator) { + case LT: + case LTE: + case GT: + case GTE: + if (!(condition.value instanceof Number)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-numeric condition"); + } + break; + case ONE_OF: + case NOT_ONE_OF: + if (!(condition.value instanceof List)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-list condition"); + } + break; + case IS_NULL: + if (!(condition.value instanceof Boolean)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-boolean condition"); + } + break; + default: + break; + } + } + } + } + + /** + * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. + * + * @throws InvalidSemverComparandException if any condition has an invalid or non-string comparand + * value. + */ + private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { + for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { + final Allocation allocation = flag.allocations.get(allocIdx); + if (allocation == null || allocation.rules == null) { + continue; + } + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition == null || condition.operator == null) { + continue; + } + switch (condition.operator) { + case SEMVER_EQ: + case SEMVER_NEQ: + case SEMVER_LT: + case SEMVER_LTE: + case SEMVER_GT: + case SEMVER_GTE: + if (!(condition.value instanceof String)) { + throw new InvalidSemverComparandException( + "flag \"" + + flagKey + + "\" allocation " + + allocIdx + + " rule has condition with operator \"" + + condition.operator + + "\" that requires string value"); + } + final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); + if (parsed == null) { + throw new InvalidSemverComparandException( + "flag \"" + + flagKey + + "\" allocation " + + allocIdx + + " rule has condition with operator \"" + + condition.operator + + "\" and invalid semantic version \"" + + condition.value + + "\""); + } + condition.semverComparand = parsed; + break; + default: + // Non-semver operators are not validated here + break; + } + } + } + } + } + + @FunctionalInterface + private interface ElementMapper { + @Nullable + T map(@Nullable Object value); + } + + /** Thrown when a SEMVER_* condition has an invalid or non-string comparand value. */ + static final class InvalidSemverComparandException extends IllegalArgumentException { + InvalidSemverComparandException(final String message) { + super(message); + } + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java similarity index 62% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java index 6f0a973a379..4b260059ae9 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ValueType.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; public enum ValueType { BOOLEAN, diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java similarity index 68% rename from products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java index 26e30aa1aad..4d9246c5965 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Variant.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java @@ -1,6 +1,6 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; -public class Variant { +public final class Variant { public final String key; public final Object value; diff --git a/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java new file mode 100644 index 00000000000..1710ce62dbe --- /dev/null +++ b/dd-openfeature/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -0,0 +1,48 @@ +package datadog.trace.api.openfeature; + +import java.util.concurrent.TimeUnit; + +/** + * The Datadog OpenFeature provider, under its former package name. + * + * @deprecated Use {@link com.datadog.openfeature.Provider} instead. + */ +@Deprecated +public class Provider extends com.datadog.openfeature.Provider { + /** Creates a provider waiting up to 30 seconds for the initial flag configuration. */ + public Provider() { + super(); + } + + /** + * Creates a provider. + * + * @param options the provider options. + */ + public Provider(final com.datadog.openfeature.Provider.Options options) { + super(options); + } + + /** + * Creates a provider. + * + * @param options the provider options. + */ + public Provider(final Options options) { + super(options); + } + + /** + * The provider options, under their former package name. + * + * @deprecated Use {@link com.datadog.openfeature.Provider.Options} instead. + */ + @Deprecated + public static class Options extends com.datadog.openfeature.Provider.Options { + @Override + public Options initTimeout(final long timeout, final TimeUnit unit) { + super.initTimeout(timeout, unit); + return this; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java new file mode 100644 index 00000000000..e8fc19c1fcb --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -0,0 +1,205 @@ +package com.datadog.openfeature; + +import static java.util.Collections.emptyMap; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.mockito.Mockito.mock; + +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Evaluates the cross-SDK canonical fixtures from the {@code ffe-system-test-data} submodule, + * parsed with the production UFC parser. + */ +class CanonicalFixtureTest { + private static final String CANONICAL_FIXTURE_PATH = + "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; + + @Test + void canonicalFixturesArePresent() throws IOException { + assertThat(canonicalTestCases().size(), greaterThan(0)); + } + + @MethodSource("canonicalTestCases") + @ParameterizedTest(name = "{0}") + void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()); + evaluator.accept( + UniversalFlagConfigParser.parse( + Files.readAllBytes(fixtureRoot().resolve("ufc-config.json")))); + + final Class targetType = targetType(testCase.variationType); + final Object defaultValue = DDEvaluator.mapValue(targetType, testCase.defaultValue); + final Object expectedValue = DDEvaluator.mapValue(targetType, testCase.resultValue); + final ProviderEvaluation details = + evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); + + assertThat(details.getValue(), equalTo(expectedValue)); + assertThat(details.getReason(), equalTo(testCase.resultReason)); + if (testCase.resultVariant != null) { + assertThat(details.getVariant(), equalTo(testCase.resultVariant)); + } + if (testCase.resultErrorCode != null) { + assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.resultErrorCode))); + } + if (testCase.resultAllocationKey != null) { + assertThat( + details.getFlagMetadata().getString("allocationKey"), + equalTo(testCase.resultAllocationKey)); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static ProviderEvaluation evaluate( + final DDEvaluator evaluator, + final Class targetType, + final String flag, + final Object defaultValue, + final EvaluationContext context) { + return evaluator.evaluate((Class) targetType, flag, defaultValue, context); + } + + @SuppressWarnings("unchecked") + static List canonicalTestCases() throws IOException { + final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); + final List result = new ArrayList<>(); + try (Stream paths = Files.list(evaluationCases)) { + final List files = + paths + .filter(path -> path.getFileName().toString().endsWith(".json")) + .sorted() + .collect(Collectors.toList()); + for (final Path file : files) { + final List testCases = (List) JsonReading.read(Files.readAllBytes(file)); + for (int index = 0; index < testCases.size(); index++) { + result.add( + new FixtureCase( + file.getFileName().toString(), + index, + (Map) testCases.get(index))); + } + } + } + assertThat(result.size(), greaterThan(0)); + return result; + } + + private static Path fixtureRoot() { + Path directory = Paths.get("").toAbsolutePath(); + while (directory != null) { + final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); + if (Files.exists(candidate.resolve("ufc-config.json")) + && Files.isDirectory(candidate.resolve("evaluation-cases"))) { + return candidate; + } + directory = directory.getParent(); + } + throw new IllegalStateException("Unable to find canonical FFE fixtures"); + } + + private static EvaluationContext context(final FixtureCase testCase) { + final MutableContext context = + new MutableContext(Value.objectToValue(testCase.attributes).asStructure().asMap()); + if (testCase.targetingKey != null) { + context.setTargetingKey(testCase.targetingKey); + } + return context; + } + + private static Class targetType(final String variationType) { + switch (variationType) { + case "BOOLEAN": + return Boolean.class; + case "INTEGER": + return Integer.class; + case "NUMERIC": + return Double.class; + case "STRING": + return String.class; + case "JSON": + return Value.class; + default: + throw new IllegalArgumentException("Unsupported variationType: " + variationType); + } + } + + /** Normalizes the integral numbers read as Long to Double, as the UFC parser does. */ + private static Object normalize(final Object value) { + if (value instanceof Long) { + return ((Long) value).doubleValue(); + } + if (value instanceof Map) { + final Map map = new java.util.LinkedHashMap<>(); + ((Map) value).forEach((k, v) -> map.put((String) k, normalize(v))); + return map; + } + if (value instanceof List) { + final List list = new ArrayList<>(); + ((List) value).forEach(v -> list.add(normalize(v))); + return list; + } + return value; + } + + static final class FixtureCase { + final String fileName; + final int index; + final Map attributes; + final Object defaultValue; + final String flag; + final String targetingKey; + final String variationType; + final Object resultValue; + final String resultReason; + final String resultErrorCode; + final String resultVariant; + final String resultAllocationKey; + + @SuppressWarnings("unchecked") + FixtureCase(final String fileName, final int index, final Map json) { + this.fileName = fileName; + this.index = index; + final Object attributes = normalize(json.get("attributes")); + this.attributes = attributes == null ? emptyMap() : (Map) attributes; + this.defaultValue = normalize(json.get("defaultValue")); + this.flag = (String) json.get("flag"); + this.targetingKey = (String) json.get("targetingKey"); + this.variationType = (String) json.get("variationType"); + final Map result = (Map) json.get("result"); + this.resultValue = normalize(result.get("value")); + this.resultReason = (String) result.get("reason"); + this.resultErrorCode = (String) result.get("errorCode"); + this.resultVariant = (String) result.get("variant"); + final Map metadata = (Map) result.get("flagMetadata"); + final Object allocationKey = metadata == null ? null : metadata.get("allocationKey"); + this.resultAllocationKey = allocationKey == null ? null : String.valueOf(allocationKey); + } + + @Override + public String toString() { + return this.fileName + "[" + this.index + "] flag=" + this.flag; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java new file mode 100644 index 00000000000..923648f8a79 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java @@ -0,0 +1,67 @@ +package com.datadog.openfeature; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; + +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Drives the span-enrichment branch of {@link DDEvaluator}, enabled through its settings. */ +class DDEvaluatorSpanEnrichmentTest { + + @Test + void enrichmentMetadataCarriesTheSplitSerialId() { + final ProviderEvaluation result = evaluate(340132); + + assertNotNull( + result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG), + "span enrichment must be on, or the assertions below pass vacuously"); + assertEquals( + Integer.valueOf(340132), + result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + } + + @Test + void enrichmentMetadataOmitsTheSerialIdWhenTheSplitHasNone() { + assertNull(evaluate(null).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + } + + private static ProviderEvaluation evaluate(final Integer serialId) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), serialId); + final Allocation allocation = + new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + final Map flags = new HashMap<>(); + flags.put( + "target", + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); + + final DDEvaluator evaluator = + new DDEvaluator( + mock(Runnable.class), + Connector.NONE, + TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true")); + evaluator.accept(new ServerConfiguration("", "", true, null, flags)); + + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); + return evaluator.evaluate(Integer.class, "target", 23, ctx); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java similarity index 58% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java index 940c84998d4..507d1bca061 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static dev.openfeature.sdk.Reason.ERROR; import static java.util.Arrays.asList; @@ -10,61 +10,45 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasEntry; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; -import datadog.trace.api.featureflag.ufc.v1.Rule; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ConditionConfiguration; +import com.datadog.openfeature.internal.ufc.ConditionOperator; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ParsedSemver; +import com.datadog.openfeature.internal.ufc.Rule; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Shard; +import com.datadog.openfeature.internal.ufc.ShardRange; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; -import java.io.IOException; -import java.lang.reflect.Type; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.time.Instant; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -72,40 +56,25 @@ public class DDEvaluatorTest { - private static final String CANONICAL_FIXTURE_PATH = - "dd-smoke-tests/openfeature/src/test/resources/ffe-system-test-data"; - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Date.class, new DateAdapter()) - .add(ShardAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .build(); - private static final JsonAdapter CONFIG_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - private static final Type FIXTURE_LIST_TYPE = - Types.newParameterizedType(List.class, FixtureCase.class); - private static final JsonAdapter> FIXTURE_LIST_ADAPTER = - MOSHI.adapter(FIXTURE_LIST_TYPE); - private static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + private static DDEvaluator newEvaluator(final Runnable configCallback) { + return new DDEvaluator(configCallback, Connector.NONE, TestSettings.of()); + } - @Test - public void testInitializeSignalsApplicationProviderActivation() throws Exception { - final FeatureFlaggingGateway.ActivationListener listener = - mock(FeatureFlaggingGateway.ActivationListener.class); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - FeatureFlaggingGateway.addActivationListener(listener); - try { - evaluator.initialize(1, MILLISECONDS, mock(EvaluationContext.class)); - - verify(listener).activate(); - } finally { - evaluator.shutdown(); - FeatureFlaggingGateway.removeActivationListener(listener); - } + /** Creates an evaluator whose runtime polls a local CDN that has no configuration. */ + private static DDEvaluator newInitializableEvaluator( + final Runnable configCallback, final LocalHttpServer cdn) { + return new DDEvaluator( + configCallback, + Connector.NONE, + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + cdn.uri("/").toString(), + "flagging.evaluation.counts.enabled", + "false")); } + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + private static Arguments[] valueMappingTestCases() { return new Arguments[] { // String mappings @@ -165,7 +134,7 @@ public void testValueMapping(final Class target, final Object value, final Ob @Test public void testEvaluateNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); assertThat(details.getValue(), equalTo(23)); @@ -176,40 +145,44 @@ public void testEvaluateNoConfig() { @Test public void testInitializeTimesOutWithoutConfig() throws Exception { final Runnable configCallback = mock(Runnable.class); - final DDEvaluator evaluator = new DDEvaluator(configCallback); - evaluator.accept(null); - try { - assertThat( - evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); - verify(configCallback, times(0)).run(); - } finally { - evaluator.shutdown(); + try (LocalHttpServer cdn = new LocalHttpServer()) { + final DDEvaluator evaluator = newInitializableEvaluator(configCallback, cdn); + evaluator.accept(null); + try { + assertThat( + evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); + verify(configCallback, times(0)).run(); + } finally { + evaluator.shutdown(); + } } } @Test public void testInitializeWaitsForNonNullConfig() throws Exception { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - final ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - final Future initialized = - executor.submit(() -> evaluator.initialize(1, SECONDS, mock(EvaluationContext.class))); + try (LocalHttpServer cdn = new LocalHttpServer()) { + final DDEvaluator evaluator = newInitializableEvaluator(mock(Runnable.class), cdn); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final Future initialized = + executor.submit(() -> evaluator.initialize(5, SECONDS, mock(EvaluationContext.class))); - evaluator.accept(null); - assertThat(initialized.isDone(), equalTo(false)); + evaluator.accept(null); + assertThat(initialized.isDone(), equalTo(false)); - evaluator.accept(mock(ServerConfiguration.class)); - assertThat(initialized.get(1, SECONDS), equalTo(true)); - } finally { - executor.shutdownNow(); - evaluator.shutdown(); + evaluator.accept(new ServerConfiguration("", "", false, null, emptyMap())); + assertThat(initialized.get(5, SECONDS), equalTo(true)); + } finally { + executor.shutdownNow(); + evaluator.shutdown(); + } } } @Test public void testEvaluateNoContext() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(mock(ServerConfiguration.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); + evaluator.accept(new ServerConfiguration("", "", false, null, emptyMap())); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); assertThat(details.getValue(), equalTo(23)); assertThat(details.getReason(), equalTo(ERROR.name())); @@ -221,7 +194,7 @@ public void testNoAllocations() { final Map flags = new HashMap<>(); flags.put("null-allocation", new Flag("target", true, null, null, null)); flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", false, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); @@ -242,12 +215,12 @@ public void testEvaluateUnsignedShardRange() { final Map variations = new HashMap<>(); variations.put("on", new Variant("on", 1)); // The selected shard is above Integer.MAX_VALUE, so this test proves that evaluation uses - // unsigned semantics after binary-compatible int storage. + // unsigned 32-bit semantics. final Shard shard = new Shard( "salt", - singletonList(new ShardRange((int) 3_699_531_192L, (int) 3_699_531_193L)), - (int) MAX_UNSIGNED_INT); + singletonList(new ShardRange(3_699_531_192L, 3_699_531_193L)), + MAX_UNSIGNED_INT); final Split split = new Split(singletonList(shard), "on", emptyMap(), null); final Allocation allocation = new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); @@ -255,7 +228,7 @@ public void testEvaluateUnsignedShardRange() { flags.put( "target", new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", false, null, flags)); final EvaluationContext context = @@ -354,7 +327,7 @@ public void observeFullEvaluationDataStampedFalseOnDefault() { public void observeFullEvaluationDataStampedOnFlagNotFoundError() { // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via // FLAG_NOT_FOUND — kept for that stamp site, correctly named. - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); @@ -369,7 +342,7 @@ public void observeFullEvaluationDataStampedOnFlagNotFoundError() { @Test public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); @@ -380,12 +353,12 @@ public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { @Test public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { - // The field is boxed so Moshi tolerates a malformed consent value in the UFC JSON without + // The field is boxed so the parser tolerates a malformed consent value in the UFC JSON without // aborting the whole parse. The evaluator must then interpret null as the privacy-preserving // default. An auto-unbox at the read site (config.observeFullEvaluationData) would NPE here. final Map flags = new HashMap<>(); flags.put("target", new Flag("target", true, ValueType.INTEGER, emptyMap(), emptyList())); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", null, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); @@ -415,75 +388,6 @@ public void exposureOmitsSerialIdWhenTheSplitHasNone() { assertNull(exposureFor(null).serial_id); } - @Test - public void legacyExposureApiDispatchesAnExposureWithoutSerialId() { - final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true); - try { - assertNull(exposureFor(7).serial_id); - } finally { - DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous); - } - } - - // ---- old-agent bootstrap probe ---- - - /** A Split from an agent that predates the serial id: the field does not exist. */ - static final class SplitWithoutSerialId {} - - /** A Split whose serialId is not the Integer the dispatch site reads. */ - static final class SplitWithWrongSerialIdType { - public long serialId; - } - - /** An ExposureEvent from an agent that predates the serial id: only the five-arg constructor. */ - static final class LegacyExposureEvent { - // Qualify exposure types that share names with the imported UFC types. - LegacyExposureEvent( - final long timestamp, - final datadog.trace.api.featureflag.exposure.Allocation allocation, - final datadog.trace.api.featureflag.exposure.Flag flag, - final datadog.trace.api.featureflag.exposure.Variant variant, - final Subject subject) {} - } - - /** - * Positive control. The probe must agree with the bootstrap actually on the classpath, or the - * negative cases below would pass for the wrong reason and the feature would ship switched off. - */ - @Test - public void probeAcceptsTheBootstrapOnTheClasspath() { - assertTrue(DDEvaluator.splitSerialIdSupported(Split.class)); - assertTrue(DDEvaluator.exposureSerialIdSupported(ExposureEvent.class)); - assertTrue(DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.get()); - assertFalse(DDEvaluator.USE_LEGACY_EXPOSURE_API.get()); - } - - @Test - public void probeRejectsAnAgentWhoseSplitHasNoSerialId() { - assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithoutSerialId.class)); - } - - @Test - public void probeRejectsAnAgentWhoseSerialIdIsNotAnInteger() { - assertFalse(DDEvaluator.splitSerialIdSupported(SplitWithWrongSerialIdType.class)); - } - - @Test - public void probeRejectsAnAgentWithoutTheSerialIdConstructor() { - assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class)); - } - - /** - * Tests legacy bootstrap behavior when Split.serialId exists but the exposure event has only the - * five-argument constructor. Split support must remain independent of exposure constructor - * support. - */ - @Test - public void probeKeepsSplitSupportWhenOnlyTheEventConstructorIsMissing() { - assertTrue(DDEvaluator.splitSerialIdSupported(Split.class)); - assertFalse(DDEvaluator.exposureSerialIdSupported(LegacyExposureEvent.class)); - } - /** * Evaluates a logging allocation whose split carries the given serial id and returns the single * dispatched exposure. Span enrichment is off here, as it is by default, so this also pins that @@ -491,19 +395,25 @@ public void probeKeepsSplitSupportWhenOnlyTheEventConstructorIsMissing() { */ private static ExposureEvent exposureFor(final Integer serialId) { final List dispatched = new ArrayList<>(); - final FeatureFlaggingGateway.ExposureListener listener = dispatched::add; - FeatureFlaggingGateway.addExposureListener(listener); - try { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), serialId); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.TRUE); - evaluateFlag( - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), true); - } finally { - FeatureFlaggingGateway.removeExposureListener(listener); - } + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()) { + @Override + void recordExposure(final ExposureEvent event) { + dispatched.add(event); + } + }; + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), serialId); + final Allocation allocation = + new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.TRUE); + final Map flags = new HashMap<>(); + flags.put( + "target", + new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); + evaluator.accept(new ServerConfiguration("", "", true, null, flags)); + evaluator.evaluate( + Integer.class, "target", 23, new MutableContext("target").setTargetingKey("user-1")); assertEquals(1, dispatched.size()); return dispatched.get(0); } @@ -544,7 +454,7 @@ private static ProviderEvaluation evaluateFlag( final Flag flag, final boolean observeFullEvaluationData) { final Map flags = new HashMap<>(); flags.put("target", flag); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); @@ -605,32 +515,19 @@ private static ProviderEvaluation evaluateWithNumericRuleOnId( flags.put( "num-rule", new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); final EvaluationContext ctx = new MutableContext(targetingKey); return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); } - @Test - public void testAllocationDateAbiAndInstantAccessors() throws Exception { - final Date startAt = Date.from(Instant.parse("2024-01-01T00:00:00Z")); - final Date endAt = Date.from(Instant.parse("2024-12-31T23:59:59Z")); - final Allocation allocation = - new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); - - assertThat(Allocation.class.getField("startAt").getType(), equalTo(Date.class)); - assertThat(Allocation.class.getField("endAt").getType(), equalTo(Date.class)); - assertThat(allocation.startAtInstant(), equalTo(startAt.toInstant())); - assertThat(allocation.endAtInstant(), equalTo(endAt.toInstant())); - } - @Test public void testAllocationWindowHonorsMicrosecondPrecision() { final Instant startAt = Instant.parse("2024-01-01T00:00:00.123456Z"); final Instant endAt = Instant.parse("2024-01-01T00:00:00.987654Z"); final Allocation allocation = - Allocation.fromInstants("allocation", emptyList(), startAt, endAt, emptyList(), true); + new Allocation("allocation", emptyList(), startAt, endAt, emptyList(), true); assertThat( DDEvaluator.isAllocationActive(allocation, startAt.minusNanos(1_000)), equalTo(false)); @@ -649,8 +546,7 @@ private static Flag semverFlag(final ConditionOperator operator, final String co final Rule rule = new Rule(singletonList(condition)); final Split split = new Split(emptyList(), "on", null, null); final Allocation allocation = - Allocation.fromInstants( - "targeted", singletonList(rule), null, null, singletonList(split), false); + new Allocation("targeted", singletonList(rule), null, null, singletonList(split), false); final Map variations = new HashMap<>(); variations.put("on", new Variant("on", true)); return new Flag("test-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation)); @@ -720,7 +616,7 @@ public void testEvaluateSemverCondition( final boolean wantMatch) { final Map flags = new HashMap<>(); flags.put("test-flag", semverFlag(operator, comparand)); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", null, null, flags)); final ProviderEvaluation details = @@ -739,7 +635,7 @@ public void testEvaluateSemverCondition( public void testEvaluateSemverConditionMissingAttribute() { final Map flags = new HashMap<>(); flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(new ServerConfiguration("", "", null, null, flags)); final ProviderEvaluation details = @@ -758,7 +654,7 @@ public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { invalidFlags.put("invalid-semver", "invalid_semver_comparand"); final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); config.invalidFlags = invalidFlags; - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(config); final ProviderEvaluation details = @@ -776,7 +672,7 @@ public void testEvaluateMalformedFlagReturnsParseError() { invalidFlags.put("malformed-flag", "invalid_flag"); final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); config.invalidFlags = invalidFlags; - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = newEvaluator(mock(Runnable.class)); evaluator.accept(config); final ProviderEvaluation details = @@ -980,208 +876,99 @@ public void testCopyPrunedContextMultipleReasonsAreSortedAndDeduplicated() { assertThat(result.truncatedReason, equalTo("max_key_length,max_value_length")); } - @Test - public void testCanonicalFixturesArePresent() throws IOException { - assertThat(canonicalTestCases().size(), greaterThan(0)); + private static Arguments[] typeCompatibilityTestCases() { + return new Arguments[] { + Arguments.of(Boolean.class, ValueType.BOOLEAN, true), + Arguments.of(String.class, ValueType.BOOLEAN, false), + Arguments.of(String.class, ValueType.STRING, true), + Arguments.of(Boolean.class, ValueType.STRING, false), + Arguments.of(Integer.class, ValueType.INTEGER, true), + Arguments.of(String.class, ValueType.INTEGER, false), + Arguments.of(Double.class, ValueType.NUMERIC, true), + Arguments.of(String.class, ValueType.NUMERIC, false), + Arguments.of(Value.class, ValueType.JSON, true), + Arguments.of(String.class, ValueType.JSON, false), + Arguments.of(String.class, null, true), + }; } - @MethodSource("canonicalTestCases") - @ParameterizedTest(name = "{0}") - public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(loadCanonicalConfiguration()); - - final Class targetType = targetType(testCase.variationType); - final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); - final Object expectedValue = mapFixtureValue(targetType, testCase.result.value); - final ProviderEvaluation details = - evaluate(evaluator, targetType, testCase.flag, defaultValue, context(testCase)); - - assertThat(details.getValue(), equalTo(expectedValue)); - assertThat(details.getReason(), equalTo(testCase.result.reason)); - if (testCase.result.variant != null) { - assertThat(details.getVariant(), equalTo(testCase.result.variant)); - } - if (testCase.result.errorCode != null) { - assertThat(details.getErrorCode(), equalTo(ErrorCode.valueOf(testCase.result.errorCode))); - } - if (testCase.result.flagMetadata != null - && testCase.result.flagMetadata.get("allocationKey") != null) { - assertThat( - details.getFlagMetadata().getString("allocationKey"), - equalTo(String.valueOf(testCase.result.flagMetadata.get("allocationKey")))); - } + @ParameterizedTest + @MethodSource("typeCompatibilityTestCases") + public void testTypeCompatibility( + final Class target, final ValueType variationType, final boolean expected) { + assertThat(DDEvaluator.isTypeCompatible(target, variationType), equalTo(expected)); } - @SuppressWarnings({"rawtypes", "unchecked"}) - private static ProviderEvaluation evaluate( - final DDEvaluator evaluator, - final Class targetType, - final String flag, - final Object defaultValue, - final EvaluationContext context) { - return evaluator.evaluate((Class) targetType, flag, defaultValue, context); - } + @Test + public void testContextCopyDetectsContainerCycles() { + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + final EvaluationContext listContext = mock(EvaluationContext.class); + when(listContext.keySet()).thenReturn(java.util.Collections.singleton("list")); + when(listContext.getValue("list")).thenReturn(listValue); - private static ServerConfiguration loadCanonicalConfiguration() throws IOException { - INVALID_FLAGS_HOLDER.get().clear(); - try { - final ServerConfiguration config = - CONFIG_ADAPTER.fromJson(read(fixtureRoot().resolve("ufc-config.json"))); - final Map invalidFlags = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; - } - validateAndCacheSemverComparands(config); - return config; - } finally { - INVALID_FLAGS_HOLDER.get().clear(); - } - } + final DDEvaluator.CopyResult listResult = DDEvaluator.copyPrunedContext(listContext); + assertThat(listResult.truncatedReason, equalTo("cycle")); - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in the configuration. Flags - * with invalid comparands are removed from the flags map and tracked in invalidFlags, matching - * the behavior of {@link com.datadog.featureflag.UniversalFlagConfigParser}. - */ - private static void validateAndCacheSemverComparands(final ServerConfiguration config) { - if (config.flags == null) { - return; - } - final Map invalidFlags = - config.invalidFlags == null ? new HashMap<>() : new HashMap<>(config.invalidFlags); - final Map flagsToRemove = new HashMap<>(); - for (final Map.Entry entry : config.flags.entrySet()) { - final String flagKey = entry.getKey(); - final Flag flag = entry.getValue(); - if (flag.allocations == null) { - continue; - } - boolean invalid = false; - for (final Allocation allocation : flag.allocations) { - if (allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; - } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - invalid = true; - break; - } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - invalid = true; - break; - } - condition.semverComparand = parsed; - break; - default: - break; - } - } - } - } - if (invalid) { - flagsToRemove.put(flagKey, flag); - invalidFlags.put(flagKey, "invalid_semver_comparand"); - } - } - for (final String flagKey : flagsToRemove.keySet()) { - config.flags.remove(flagKey); - } - if (!invalidFlags.isEmpty()) { - config.invalidFlags = invalidFlags; - } + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + final EvaluationContext structureContext = mock(EvaluationContext.class); + when(structureContext.keySet()).thenReturn(java.util.Collections.singleton("structure")); + when(structureContext.getValue("structure")).thenReturn(structureValue); + + final DDEvaluator.CopyResult structureResult = DDEvaluator.copyPrunedContext(structureContext); + assertThat(structureResult.truncatedReason, equalTo("cycle")); } - private static List canonicalTestCases() throws IOException { - final Path evaluationCases = fixtureRoot().resolve("evaluation-cases"); - final List result = new ArrayList<>(); - - try (final Stream paths = Files.list(evaluationCases)) { - final List files = - paths - .filter(path -> path.getFileName().toString().endsWith(".json")) - .sorted((left, right) -> left.getFileName().compareTo(right.getFileName())) - .collect(Collectors.toList()); - for (final Path file : files) { - final List testCases = FIXTURE_LIST_ADAPTER.fromJson(read(file)); - if (testCases == null) { - throw new JsonDataException("Fixture file did not contain an array: " + file); - } - for (int index = 0; index < testCases.size(); index++) { - final FixtureCase testCase = testCases.get(index); - testCase.fileName = file.getFileName().toString(); - testCase.index = index; - result.add(testCase); - } - } - } + @Test + public void testCopyPrunedContextHandlesEmptyAndScalarValues() { + assertThat(DDEvaluator.copyPrunedContext(null).attrs, equalTo(emptyMap())); + assertThat(DDEvaluator.copyPrunedContext(new MutableContext()).attrs, equalTo(emptyMap())); - assertThat(result.size(), greaterThan(0)); - return result; - } + final EvaluationContext nullContext = mock(EvaluationContext.class); + when(nullContext.keySet()) + .thenReturn(new java.util.LinkedHashSet<>(asList("java-null", "openfeature-null"))); + when(nullContext.getValue("java-null")).thenReturn(null); + when(nullContext.getValue("openfeature-null")).thenReturn(new Value()); + final Map snapshot = DDEvaluator.snapshotValues(nullContext); + assertNull(snapshot.get("java-null")); + assertThat(snapshot.get("openfeature-null").isNull(), equalTo(true)); + final DDEvaluator.CopyResult nullResult = DDEvaluator.copyPrunedContext(nullContext); + assertThat(nullResult.attrs, hasEntry("java-null", null)); + assertThat(nullResult.attrs, hasEntry("openfeature-null", null)); - private static Path fixtureRoot() { - Path directory = Paths.get("").toAbsolutePath(); - while (directory != null) { - final Path candidate = directory.resolve(CANONICAL_FIXTURE_PATH); - if (Files.exists(candidate.resolve("ufc-config.json")) - && Files.isDirectory(candidate.resolve("evaluation-cases"))) { - return candidate; - } - directory = directory.getParent(); - } - throw new IllegalStateException("Unable to find canonical FFE fixtures"); + final MutableContext scalarContext = new MutableContext(); + scalarContext.add("boolean", true); + scalarContext.add("number", 42); + scalarContext.add("instant", Instant.parse("2026-08-20T00:00:00Z")); + final DDEvaluator.CopyResult scalarResult = DDEvaluator.copyPrunedContext(scalarContext); + assertThat(scalarResult.attrs, hasEntry("boolean", true)); + assertThat(scalarResult.attrs, hasEntry("number", 42)); + assertThat(scalarResult.attrs, hasEntry("instant", "2026-08-20T00:00:00Z")); } - private static String read(final Path path) throws IOException { - return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - } + @Test + public void testFlattenValuesHandlesJavaNullAndContainerCycles() { + final Map values = new HashMap<>(); + values.put("null", null); - private static EvaluationContext context(final FixtureCase testCase) { - final Map attributes = - testCase.attributes == null ? emptyMap() : testCase.attributes; - final MutableContext context = - new MutableContext(Value.objectToValue(attributes).asStructure().asMap()); - if (testCase.targetingKey != null) { - context.setTargetingKey(testCase.targetingKey); - } - return context; - } + final List cyclicList = new ArrayList<>(); + final Value listValue = new Value(cyclicList); + cyclicList.add(listValue); + values.put("list", listValue); - private static Class targetType(final String variationType) { - switch (variationType) { - case "BOOLEAN": - return Boolean.class; - case "INTEGER": - return Integer.class; - case "NUMERIC": - return Double.class; - case "STRING": - return String.class; - case "JSON": - return Value.class; - default: - throw new IllegalArgumentException("Unsupported variationType: " + variationType); - } - } + final dev.openfeature.sdk.MutableStructure cyclicStructure = + new dev.openfeature.sdk.MutableStructure(); + final Value structureValue = new Value(cyclicStructure); + cyclicStructure.add("self", structureValue); + values.put("structure", structureValue); - private static Object mapFixtureValue(final Class targetType, final Object value) { - return DDEvaluator.mapValue(targetType, value); + final Map flattened = DDEvaluator.flattenValues(values); + assertThat(flattened, hasEntry("null", null)); + assertThat(flattened.size(), equalTo(1)); } private static Map mapOf(final Object... props) { @@ -1194,251 +981,4 @@ private static Map mapOf(final Object... props) { } return result; } - - private static final class FixtureCase { - Map attributes = emptyMap(); - Object defaultValue; - String flag; - FixtureResult result; - String targetingKey; - String variationType; - transient String fileName; - transient int index; - - @Override - public String toString() { - return fileName + "[" + index + "] flag=" + flag; - } - } - - private static final class FixtureResult { - Object value; - String reason; - String errorCode; - String variant; - Map flagMetadata = emptyMap(); - } - - /** - * Mirrors the production parser's binary-compatible uint32 representation for fixture parsing. - */ - private static final class ShardAdapter extends JsonAdapter { - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || type != Shard.class) { - return null; - } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - }; - - private final JsonAdapter delegate; - - private ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Override - public Shard fromJson(final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); - } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); - } - - @Override - public void toJson(final JsonWriter writer, final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static int toUnsignedInt(final Long value, final String fieldName) { - if (value == null) { - return 0; - } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new IllegalArgumentException("flag contains an invalid " + fieldName + " value"); - } - return value.intValue(); - } - - private static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } - - private static final class ShardRangeJson { - Long start; - Long end; - } - - /** Reads the flags map with per-flag failure isolation, matching the production parser. */ - private static final class FlagMapAdapter extends JsonAdapter> { - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - private static final JsonAdapter.Factory FACTORY = - (type, annotations, moshi) -> { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - }; - - private final JsonAdapter flagAdapter; - - private FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } - - @Override - public Map fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException ignored) { - INVALID_FLAGS_HOLDER.get().put(flagKey, "invalid_flag"); - // A malformed flag must not prevent valid flags in the same configuration from loading. - } - } - reader.endObject(); - return flags; - } - - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; - } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; - } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains a split with missing shards"); - } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains invalid shards"); - } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } - } - } - } - } - } - - private static void validateConditionOperands( - final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; - } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-numeric condition"); - } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-list condition"); - } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new IllegalArgumentException( - "flag \"" + flagKey + "\" has a non-boolean condition"); - } - break; - default: - break; - } - } - } - } - - @Override - public void toJson(final JsonWriter writer, final Map value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - private static final class DateAdapter extends JsonAdapter { - @Override - public Date fromJson(final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - try { - return Date.from( - DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(reader.nextString(), Instant::from)); - } catch (final Exception ignored) { - return null; - } - } - - @Override - public void toJson(final JsonWriter writer, final Date value) throws IOException { - if (value == null) { - writer.nullValue(); - return; - } - writer.value(value.toInstant().toString()); - } - } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java similarity index 87% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java index 3b7910d4712..63bcdd23519 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -14,10 +15,8 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; @@ -33,8 +32,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** @@ -43,53 +40,24 @@ */ class FlagEvalLoggingHookTest { - @BeforeEach - void enableFlagEvaluationEnqueue() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @AfterEach - void resetFlagEvaluationEnqueue() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - // Clear any dispatched UFC so an observeFullEvaluationData value can't leak into other tests - // that share the static gateway. - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - // ---- helpers ---- - /** - * Creates a writer that captures the enqueued event for assertion. Uses an anonymous class since - * FlagEvaluationWriter has multiple abstract methods. - */ - private FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - }; + /** Creates a pipeline mock that captures the enqueued event for assertion. */ + private static FlagEvaluationPipeline capturingWriter(final AtomicReference ref) { + final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + doAnswer( + invocation -> { + ref.set(invocation.getArgument(0)); + return null; + }) + .when(writer) + .enqueue(any()); + return writer; } - private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationWriter writer) { - return new FlagEvalLoggingHook<>(writer); + private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationPipeline writer) { + return new FlagEvalLoggingHook<>(() -> writer); } private static FlagEvaluationDetails details( @@ -361,7 +329,7 @@ void successPathHasNullErrorMessage() { @Test void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); when(writer.hasCapacityForEnqueue()).thenReturn(true); final FlagEvalLoggingHook hook = hookWithWriter(writer); @@ -381,7 +349,7 @@ void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { @Test void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); when(writer.hasCapacityForEnqueue()).thenReturn(false); final FlagEvalLoggingHook hook = hookWithWriter(writer); @@ -398,7 +366,7 @@ void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { @Test void countContextTruncatedCalledWhenContextIsTruncated() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); when(writer.hasCapacityForEnqueue()).thenReturn(true); final FlagEvalLoggingHook hook = hookWithWriter(writer); @@ -430,7 +398,7 @@ void countContextTruncatedCalledWhenContextIsTruncated() { @Test void countContextTruncatedNotCalledWhenNoTruncation() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); when(writer.hasCapacityForEnqueue()).thenReturn(true); final FlagEvalLoggingHook hook = hookWithWriter(writer); @@ -457,24 +425,6 @@ void countContextTruncatedNotCalledWhenNoTruncation() { verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); } - @Test - void enqueueDisabledIsNoOpBeforeWriterLookup() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - final AtomicReference writerResolved = new AtomicReference<>(false); - final FlagEvalLoggingHook hook = - new FlagEvalLoggingHook<>( - () -> { - writerResolved.set(true); - throw new AssertionError("writer should not be resolved when enqueue is disabled"); - }); - final FlagEvaluationDetails det = - details("flag", "v", "v", Reason.TARGETING_MATCH.name(), null); - - hook.finallyAfter(hookCtxWithTargetingKey("flag", "user-1"), det, Collections.emptyMap()); - - assertFalse(writerResolved.get()); - } - // ---- test: writer=null -> no-op (killswitch off / not yet started) ---- @Test @@ -504,7 +454,7 @@ void writerLookupLinkageErrorIsNoOp() { @Test void detailsNullIsNoOp() { - final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); final FlagEvalLoggingHook hook = hookWithWriter(writer); // Should not throw @@ -665,17 +615,6 @@ void protectedPathSkipsEvaluationContextCapture() { assertTrue(captured.get().attrs.isEmpty()); } - @Test - void ignoresGatewayConsentEvenWhenItDisagreesWithMetadata() { - // Gateway says on, metadata says off; hook must trust metadata. - FeatureFlaggingGateway.dispatch(observeConfig(true)); - try { - assertFalse(enqueuedEventWithConsentMetadata(false).observeFullEvaluationData); - } finally { - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - } - /** * Fires the hook once for a simple targeted evaluation whose metadata carries the given consent * value ({@code null} = key absent) and returns the enqueued event. @@ -708,13 +647,4 @@ private static ImmutableMetadata consentOffMetadata() { .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) .build(); } - - private static ServerConfiguration observeConfig(final boolean observeFullEvaluationData) { - return new ServerConfiguration( - "2024-04-17T19:40:53.716Z", - "SERVER", - observeFullEvaluationData, - null, - Collections.emptyMap()); - } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java similarity index 99% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java index 322a06d2de7..cb56826fbb0 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsHookTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java new file mode 100644 index 00000000000..80d212a10f8 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsLateSdkForkedTest.java @@ -0,0 +1,52 @@ +package com.datadog.openfeature; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.LongCounterBuilder; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.MeterBuilder; +import io.opentelemetry.api.metrics.MeterProvider; +import org.junit.jupiter.api.Test; + +class FlagEvalMetricsLateSdkForkedTest { + @Test + void evaluationBeforeSdkRegistrationDoesNotClaimTheGlobalProvider() { + GlobalOpenTelemetry.resetForTest(); + final FlagEvalMetrics metrics = new FlagEvalMetrics(); + metrics.record("before", "on", "STATIC", null, null); + assertFalse(GlobalOpenTelemetry.isSet()); + + final OpenTelemetry telemetry = mock(OpenTelemetry.class); + final MeterProvider provider = mock(MeterProvider.class); + final MeterBuilder meterBuilder = mock(MeterBuilder.class); + final Meter meter = mock(Meter.class); + final LongCounterBuilder counterBuilder = mock(LongCounterBuilder.class); + final LongCounter counter = mock(LongCounter.class); + when(telemetry.getMeterProvider()).thenReturn(provider); + when(provider.meterBuilder("ddtrace.openfeature")).thenReturn(meterBuilder); + when(meterBuilder.build()).thenReturn(meter); + when(meter.counterBuilder("feature_flag.evaluations")).thenReturn(counterBuilder); + when(counterBuilder.setUnit("{evaluation}")).thenReturn(counterBuilder); + when(counterBuilder.setDescription("Number of feature flag evaluations")) + .thenReturn(counterBuilder); + when(counterBuilder.build()).thenReturn(counter); + try { + assertDoesNotThrow(() -> GlobalOpenTelemetry.set(telemetry)); + metrics.record("after", "on", "STATIC", null, null); + verify(counter).add(eq(1L), any(Attributes.class)); + } finally { + metrics.close(); + GlobalOpenTelemetry.resetForTest(); + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java similarity index 99% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java index 214fadc7274..61e0ab4dd4b 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalMetricsTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java similarity index 65% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java index d7adf645e74..4008aac4974 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java @@ -1,6 +1,8 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; -import static datadog.trace.api.openfeature.Provider.METADATA; +import static com.datadog.openfeature.Provider.METADATA; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.CoreMatchers.equalTo; @@ -16,11 +18,21 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.openfeature.Provider.Options; +import com.datadog.openfeature.Provider.Options; +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; @@ -28,7 +40,6 @@ import dev.openfeature.sdk.Features; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.ProviderEvaluation; @@ -37,12 +48,17 @@ import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.io.IOException; import java.lang.reflect.Field; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import org.junit.jupiter.api.AfterEach; @@ -57,19 +73,53 @@ public class ProviderTest { private static final long EVENT_TIMEOUT_SECONDS = 10; private ExecutorService executor; + private LocalHttpServer cdn; + private DDEvaluator evaluator; @BeforeEach - public void setup() { - executor = Executors.newSingleThreadExecutor(); + public void setup() throws IOException { + this.executor = Executors.newSingleThreadExecutor(); + this.cdn = new LocalHttpServer(); } @AfterEach public void tearDown() { - executor.shutdownNow(); + this.executor.shutdownNow(); OpenFeatureAPI.getInstance().shutdown(); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); + this.cdn.close(); + } + + /** + * Creates a provider backed by a real evaluator, whose runtime polls a local CDN that has no + * configuration. Configurations are then dispatched with {@link #dispatch}. + */ + private Provider newProvider(final Options options) { + return newProvider(options, Connector.NONE); + } + + private Provider newProvider(final Options options, final Connector connector) { + final Provider[] providerRef = new Provider[1]; + this.evaluator = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + connector, + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + this.cdn.uri("/").toString())); + providerRef[0] = new Provider(options, this.evaluator); + return providerRef[0]; + } + + private Provider newProvider() { + return newProvider(new Options().initTimeout(30, SECONDS)); + } + + private void dispatch(final ServerConfiguration configuration) { + this.evaluator.accept(configuration); + } + + private static ServerConfiguration emptyConfiguration() { + return new ServerConfiguration("", "", false, null, emptyMap()); } @Test @@ -77,12 +127,12 @@ public void testSetProvider() throws Exception { final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); final CompletableFuture readyEvent = new CompletableFuture<>(); api.onProviderReady(readyEvent::complete); - api.setProvider(new Provider()); + api.setProvider(newProvider()); final Client client = api.getClient(); assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); } @@ -90,12 +140,13 @@ public void testSetProvider() throws Exception { @Test public void testSetProviderAndWait() throws Exception { final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - final Future provider = executor.submit(() -> api.setProviderAndWait(new Provider())); + final Provider newProvider = newProvider(); + final Future provider = this.executor.submit(() -> api.setProviderAndWait(newProvider)); final Client client = api.getClient(); assertThat(client.getProviderState(), equalTo(ProviderState.NOT_READY)); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); provider.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); } @@ -110,12 +161,12 @@ public void testSetProviderAndWaitTimeoutRecoversWhenConfigurationArrives() thro assertThrows( ProviderNotReadyError.class, - () -> api.setProviderAndWait(new Provider(new Options().initTimeout(10, MILLISECONDS)))); + () -> api.setProviderAndWait(newProvider(new Options().initTimeout(10, MILLISECONDS)))); assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); assertFalse(readyEvent.isDone()); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); final EventDetails eventDetails = readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); @@ -263,8 +314,11 @@ public ProviderEvaluation evaluate( @Test public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throws Exception { final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - api.setProviderAndWait(new Provider()); + final Provider provider = newProvider(); + this.executor.submit(() -> api.setProviderAndWait(provider)); + awaitInitialization(); + dispatch(emptyConfiguration()); + awaitReady(api); final Client client = api.getClient(); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); @@ -277,7 +331,7 @@ public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throw client.on(ProviderEvent.PROVIDER_ERROR, errorEventHandler); client.on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, configChangedEventHandler); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + dispatch(null); final EventDetails eventDetails = errorEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.ERROR)); assertThat(eventDetails.getProviderName(), equalTo(METADATA)); @@ -287,11 +341,11 @@ public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throw assertThat(evalDetails.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); client.on(ProviderEvent.PROVIDER_READY, readyEventHandler); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); readyEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); assertThat(client.getProviderState(), equalTo(ProviderState.READY)); - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); + dispatch(emptyConfiguration()); configChangedEvent.get(EVENT_TIMEOUT_SECONDS, SECONDS); verify(errorEventHandler, times(1)).accept(any()); verify(readyEventHandler, times(1)).accept(any()); @@ -299,24 +353,34 @@ public void testNullConfigurationAfterReadyTransitionsToErrorAndRecovers() throw } @Test - public void testFailureToLoadInternalApi() { - @SuppressWarnings("unchecked") - final Consumer consumer = mock(Consumer.class); + public void testDisabledProductFailsInitialization() { + final Provider[] providerRef = new Provider[1]; + final DDEvaluator disabled = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + Connector.NONE, + TestSettings.of("feature.flags.enabled", "false")); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), disabled); + + final FatalError error = + assertThrows( + FatalError.class, + () -> OpenFeatureAPI.getInstance().setProviderAndWait(providerRef[0])); + assertThat(error.getCause().getMessage(), equalTo("Feature Flags is disabled")); + } - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.onProviderError(consumer); + @Test + public void testRemoteConfigurationWithoutAgentFailsInitialization() { + final Provider[] providerRef = new Provider[1]; + final DDEvaluator remoteConfig = + new DDEvaluator( + () -> providerRef[0].onConfigurationChange(), + Connector.NONE, + TestSettings.of("feature.flags.configuration.source", "remote_config")); + providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), remoteConfig); assertThrows( - FatalError.class, - () -> - api.setProviderAndWait( - new Provider() { - @Override - protected Class loadEvaluatorClass() throws ClassNotFoundException { - throw new ClassNotFoundException( - "Class " + FeatureFlaggingGateway.class.getName() + " not found"); - } - })); + FatalError.class, () -> OpenFeatureAPI.getInstance().setProviderAndWait(providerRef[0])); } @Test @@ -330,59 +394,43 @@ public void testGetProviderHooksReturnsFlagEvalMetricsHook() { assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); } - @Test - public void testGetProviderHooksSkipsFlagEvalLoggingHookOnLinkageFailure() { - Provider provider = - new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)) { - @Override - Hook buildFlagEvalLoggingHook() { - throw new NoClassDefFoundError("old bootstrap"); - } - }; - - List hooks = provider.getProviderHooks(); - - assertThat(hooks.size(), equalTo(1)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); - } - @Test public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Exception { - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); - final AtomicReference captured = new AtomicReference<>(); - FeatureFlaggingGateway.setFlagEvalWriter(capturingWriter(captured)); - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - when(evaluator.evaluate(eq(String.class), eq("logged-flag"), eq("default"), any())) - .thenReturn( - ProviderEvaluation.builder() - .value("value") - .reason("STATIC") - .variant("variant-1") - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "allocation-1") - .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) - .build()) - .build()); + final BlockingQueue posted = new LinkedBlockingQueue<>(); + final Provider provider = + newProvider( + new Options().initTimeout(10, SECONDS), + new ProxyConnector( + (route, json) -> { + if ("flagevaluation".equals(route)) { + posted.add(json); + } + })); final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); + this.executor.submit(() -> api.setProviderAndWait(provider)); + awaitInitialization(); + dispatch(loggedFlagConfiguration()); + awaitReady(api); final MutableContext context = new MutableContext("user-1"); context.add("region", "us-east-1"); final FlagEvaluationDetails details = api.getClient().getStringDetails("logged-flag", "default", context); + // Shutting down the provider flushes the evaluation pipeline. + api.shutdown(); assertThat(details.getValue(), equalTo("value")); - final FlagEvalEvent event = captured.get(); - assertThat(event.flagKey, equalTo("logged-flag")); - assertThat(event.variant, equalTo("variant-1")); - assertThat(event.allocationKey, equalTo("allocation-1")); - assertThat(event.targetingKey, equalTo("user-1")); - assertThat(event.evalTimeMs, equalTo(1_700_000_000_000L)); - assertThat(event.attrs.get("region"), equalTo("us-east-1")); + final byte[] payload = posted.poll(EVENT_TIMEOUT_SECONDS, SECONDS); + assertTrue(payload != null, "flag evaluation payload must be posted on shutdown"); + final Map event = firstFlagEvaluation(payload); + assertThat(((Map) event.get("flag")).get("key"), equalTo("logged-flag")); + assertThat(((Map) event.get("variant")).get("key"), equalTo("variant-1")); + assertThat(((Map) event.get("allocation")).get("key"), equalTo("allocation-1")); + assertThat(event.get("targeting_key"), equalTo("user-1")); + assertThat(event.get("evaluation_count"), equalTo(1L)); + assertThat( + ((Map) ((Map) event.get("context")).get("evaluation")).get("region"), + equalTo("us-east-1")); } @Test @@ -437,7 +485,6 @@ private static Arguments[] providerMethods() { @ParameterizedTest public void testProviderEvaluation( final String flag, final E defaultValue, final EvaluateMethod method) throws Exception { - FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); final Evaluator evaluator = mock(Evaluator.class); when(evaluator.initialize(eq(10L), eq(SECONDS), any())).thenReturn(true); when(evaluator.hasConfiguration()).thenReturn(true); @@ -480,29 +527,74 @@ private static Consumer completingHandler( return handler; } - private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { - return new FlagEvaluationWriter() { - @Override - public void enqueue(final FlagEvalEvent event) { - ref.set(event); - } - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } + /** Waits for the evaluator to start waiting for its initial configuration. */ + private void awaitInitialization() throws InterruptedException { + final long deadline = System.nanoTime() + SECONDS.toNanos(EVENT_TIMEOUT_SECONDS); + while (this.evaluator.runtime() == null && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertTrue(this.evaluator.runtime() != null, "provider initialization must start"); + } - @Override - public void countPreQueueOverflow() {} + private static void awaitReady(final OpenFeatureAPI api) throws InterruptedException { + final long deadline = System.nanoTime() + SECONDS.toNanos(EVENT_TIMEOUT_SECONDS); + while (api.getClient().getProviderState() != ProviderState.READY + && System.nanoTime() < deadline) { + Thread.sleep(5); + } + assertThat(api.getClient().getProviderState(), equalTo(ProviderState.READY)); + } - @Override - public void countContextTruncated(final String reason) {} + private static ServerConfiguration loggedFlagConfiguration() { + final Map variations = new HashMap<>(); + variations.put("variant-1", new Variant("variant-1", "value")); + final Split split = new Split(java.util.Collections.emptyList(), "variant-1", emptyMap(), null); + final Allocation allocation = + new Allocation("allocation-1", null, null, null, singletonList(split), Boolean.TRUE); + final Map flags = new HashMap<>(); + flags.put( + "logged-flag", + new Flag("logged-flag", true, ValueType.STRING, variations, singletonList(allocation))); + return new ServerConfiguration("", "", true, null, flags); + } - @Override - public void start() {} + @SuppressWarnings("unchecked") + private static Map firstFlagEvaluation(final byte[] payload) { + final Map request = JsonReading.readObject(payload); + return (Map) ((List) request.get("flagEvaluations")).get(0); + } - @Override - public void close() {} - }; + /** A connector only providing an event platform proxy. */ + private static final class ProxyConnector implements Connector { + private final EventTransport proxy; + + ProxyConnector(final EventTransport proxy) { + this.proxy = proxy; + } + + @Override + public String setting(final String key) { + return null; + } + + @Override + public ConfigurationSource remoteConfiguration() { + return null; + } + + @Override + public EventTransport eventProxy() { + return this.proxy; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } } } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java similarity index 77% rename from products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java index 2ce0931e116..e9f261e9ad3 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/SpanEnrichmentHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.openfeature; +package com.datadog.openfeature; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -6,8 +6,7 @@ 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 com.datadog.openfeature.internal.connector.SpanEnricher; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; import dev.openfeature.sdk.Hook; @@ -22,30 +21,30 @@ 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. + * Capture-side unit suite for APM feature-flag span enrichment. The hook records evaluations to the + * {@link SpanEnricher} and the agent does the accumulation, so these tests assert the recorded + * events (not span tags) 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); - } + private final SpanEnricher enricher = + new SpanEnricher() { + @Override + public void serialId(final int serialId, final boolean doLog, final String targetingKey) { + SpanEnrichmentHookTest.this.captured.add( + new SpanEnrichmentEvent(true, serialId, doLog, targetingKey, null, null)); + } + + @Override + public void runtimeDefault(final String flagKey, final Object defaultValue) { + SpanEnrichmentHookTest.this.captured.add( + new SpanEnrichmentEvent(false, 0, false, null, flagKey, defaultValue)); + } + }; // ---- helpers ---- @@ -85,7 +84,7 @@ private static HookContext ctx(final String flagKey, final String target @Test void serialIdWithDoLogDispatchesSerialAndSubject() { - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .finallyAfter( ctx("flag", "user-1"), details("flag", "on", "v", metadata(42, true)), @@ -101,7 +100,7 @@ void serialIdWithDoLogDispatchesSerialAndSubject() { @Test void serialIdWithoutDoLogStillDispatchesSerialButNotDoLog() { - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .finallyAfter( ctx("flag", "user-1"), details("flag", "on", "v", metadata(7, false)), @@ -123,7 +122,7 @@ void wrongTypedSerialIdDispatchesNothing() { .addString(SpanEnrichmentHook.METADATA_SERIAL_ID, "not-a-number") .addBoolean(SpanEnrichmentHook.METADATA_DO_LOG, true) .build(); - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .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"); @@ -134,7 +133,7 @@ void wrongTypedSerialIdDispatchesNothing() { @Test void missingVariantDispatchesRuntimeDefaultWithNativeMap() { final Map objectValue = Collections.singletonMap("k", "val"); - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .finallyAfter( ctx("obj-flag", "user-1"), details("obj-flag", null, objectValue, ImmutableMetadata.builder().build()), @@ -154,7 +153,7 @@ void missingVariantUnwrapsOpenFeatureValueStructureToNativeMap() { inner.put("enabled", new Value(true)); final Value structureDefault = new Value(new ImmutableStructure(inner)); - new SpanEnrichmentHook() + new SpanEnrichmentHook(this.enricher) .finallyAfter( ctx("struct-flag", "user-1"), details("struct-flag", null, structureDefault, ImmutableMetadata.builder().build()), @@ -197,7 +196,7 @@ void unwrapPassesNativeValuesThrough() { @Test void nullDetailsDispatchesNothing() { - new SpanEnrichmentHook().finallyAfter(null, null, null); + new SpanEnrichmentHook(this.enricher).finallyAfter(null, null, null); assertTrue(captured.isEmpty()); } @@ -238,4 +237,53 @@ void getProviderHooksReturnsSameInstanceEachCall() { gateOn.getProviderHooks() == gateOn.getProviderHooks(), "gate on => getProviderHooks allocates nothing (same instance)"); } + + /** A recorded span enrichment. */ + private static 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; + + 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; + } + + boolean hasSerialId() { + return this.serialIdPresent; + } + + int serialId() { + return this.serialId; + } + + boolean doLog() { + return this.doLog; + } + + String targetingKey() { + return this.targetingKey; + } + + String flagKey() { + return this.flagKey; + } + + Object defaultValue() { + return this.defaultValue; + } + } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java new file mode 100644 index 00000000000..a030740cefc --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java @@ -0,0 +1,129 @@ +package com.datadog.openfeature.internal; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class EventQueueTest { + @Test + void rejectsOffersBeyondCapacity() { + final EventQueue queue = new EventQueue<>(2); + + assertTrue(queue.offer("a")); + assertTrue(queue.offer("b")); + assertFalse(queue.offer("c")); + + assertEquals(2, queue.size()); + assertEquals(2, queue.capacity()); + assertEquals("a", queue.poll()); + assertTrue(queue.offer("c")); + } + + @Test + void pollsInFifoOrderAndReturnsNullWhenEmpty() { + final EventQueue queue = new EventQueue<>(4); + queue.offer(1); + queue.offer(2); + + assertEquals(1, queue.poll()); + assertEquals(2, queue.poll()); + assertNull(queue.poll()); + assertEquals(0, queue.size()); + } + + @Test + void timedPollWaitsForAnElement() throws Exception { + final EventQueue queue = new EventQueue<>(1); + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + executor.submit( + () -> { + MILLISECONDS.sleep(50); + return queue.offer("late"); + }); + + assertEquals("late", queue.poll(5, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void timedPollTimesOut() throws Exception { + final EventQueue queue = new EventQueue<>(1); + final long start = System.nanoTime(); + + assertNull(queue.poll(30, MILLISECONDS)); + + assertTrue(System.nanoTime() - start >= MILLISECONDS.toNanos(30)); + } + + @Test + void timedPollIsInterruptible() { + final EventQueue queue = new EventQueue<>(1); + Thread.currentThread().interrupt(); + + assertThrows(InterruptedException.class, () -> queue.poll(5, TimeUnit.SECONDS)); + assertFalse(Thread.currentThread().isInterrupted()); + } + + @Test + void drainsUpToTheLimit() { + final EventQueue queue = new EventQueue<>(8); + for (int i = 0; i < 5; i++) { + queue.offer(i); + } + final List drained = new ArrayList<>(); + + queue.drain(drained::add, 3); + + assertEquals(List.of(0, 1, 2), drained); + assertEquals(2, queue.size()); + queue.drain(drained::add, 10); + assertEquals(List.of(0, 1, 2, 3, 4), drained); + } + + @Test + void concurrentProducersNeverExceedCapacity() throws Exception { + final int capacity = 1000; + final EventQueue queue = new EventQueue<>(capacity); + final int producers = 8; + final ExecutorService executor = Executors.newFixedThreadPool(producers); + final CountDownLatch start = new CountDownLatch(1); + final AtomicInteger accepted = new AtomicInteger(); + try { + for (int p = 0; p < producers; p++) { + executor.submit( + () -> { + start.await(); + for (int i = 0; i < 500; i++) { + if (queue.offer(i)) { + accepted.incrementAndGet(); + } + } + return null; + }); + } + start.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + + assertEquals(capacity, accepted.get()); + assertEquals(capacity, queue.size()); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java new file mode 100644 index 00000000000..685d47ddece --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -0,0 +1,212 @@ +package com.datadog.openfeature.internal; + +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class FeatureFlagsRuntimeTest { + private static final byte[] EMPTY_UFC = + "{\"environment\":{\"name\":\"Test\"},\"flags\":{}}".getBytes(StandardCharsets.UTF_8); + + @Test + void disabledProductIsRejected() { + final IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.create( + Connector.NONE, TestSettings.of("feature.flags.enabled", "false"))); + + assertEquals("Feature Flags is disabled", error.getMessage()); + } + + @Test + void remoteConfigurationRequiresTheJavaAgent() { + final IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.create( + Connector.NONE, + TestSettings.of("feature.flags.configuration.source", "remote_config"))); + + assertTrue(error.getMessage().contains("requires the Datadog Java agent")); + } + + @Test + void agentlessWithoutApiKeyOrProxyDisablesEventDelivery() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + + assertNull(runtime.evaluations()); + } + + @Test + void evaluationCountsKillSwitchDisablesTheEvaluationPipeline() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(null, (route, json) -> {}), + TestSettings.of("flagging.evaluation.counts.enabled", "false")); + + assertNull(runtime.evaluations()); + } + + @Test + void eventProxyEnablesBothPipelines() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(new TestConnector(null, (route, json) -> {}), TestSettings.of()); + + assertNotNull(runtime.evaluations()); + } + + @Test + void remoteConfigurationDeliversParsedConfigurationsAndReplaysToLateListeners() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + final List early = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener(early::add); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + source.listener.accept("{not json".getBytes(StandardCharsets.UTF_8)); + final List late = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener(late::add); + + assertEquals(1, early.size()); + assertEquals("Test", early.get(0).environment.name); + assertEquals(1, late.size()); + assertSame(early.get(0), late.get(0)); + + source.listener.accept(null); + assertEquals(2, early.size()); + assertNull(early.get(1)); + } finally { + runtime.stop(); + } + assertTrue(source.closed); + } + + @Test + void removedListenersAreNotNotified() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + @SuppressWarnings("unchecked") + final Consumer listener = mock(Consumer.class); + runtime.addConfigurationListener(listener); + runtime.removeConfigurationListener(listener); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + } finally { + runtime.stop(); + } + + verify(listener, org.mockito.Mockito.never()).accept(org.mockito.ArgumentMatchers.any()); + } + + @Test + void sharedRuntimeIsReferenceCounted() { + final TestConfigurationSource source = new TestConfigurationSource(); + final Connector connector = new TestConnector(source, null); + final FeatureFlagsRuntime first = + FeatureFlagsRuntime.acquire( + connector, TestSettings.of("feature.flags.configuration.source", "remote_config")); + final FeatureFlagsRuntime second = FeatureFlagsRuntime.acquire(connector, TestSettings.of()); + try { + assertSame(first, second); + FeatureFlagsRuntime.release(); + assertTrue(!source.closed); + } finally { + FeatureFlagsRuntime.release(); + } + assertTrue(source.closed); + // Extra releases are ignored. + FeatureFlagsRuntime.release(); + } + + @Test + void recordsExposuresOnlyWhenDeliveryIsEnabled() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + // Must not throw without an exposure pipeline. + runtime.recordExposure( + new com.datadog.openfeature.internal.exposure.ExposureEvent( + 1L, + new com.datadog.openfeature.internal.exposure.Allocation("a"), + new com.datadog.openfeature.internal.exposure.Flag("f"), + new com.datadog.openfeature.internal.exposure.Variant("v"), + new com.datadog.openfeature.internal.exposure.Subject("s", emptyMap()), + null)); + } + + private static final class TestConfigurationSource implements ConfigurationSource { + volatile Consumer listener; + volatile boolean closed; + + @Override + public AutoCloseable subscribe(final Consumer listener) { + this.listener = listener; + return () -> this.closed = true; + } + } + + private static final class TestConnector implements Connector { + private final ConfigurationSource remoteConfiguration; + private final EventTransport eventProxy; + + TestConnector(final ConfigurationSource remoteConfiguration, final EventTransport eventProxy) { + this.remoteConfiguration = remoteConfiguration; + this.eventProxy = eventProxy; + } + + @Override + public String setting(final String key) { + return null; + } + + @Override + public ConfigurationSource remoteConfiguration() { + return this.remoteConfiguration; + } + + @Override + public EventTransport eventProxy() { + return this.eventProxy; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/JsonReading.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/JsonReading.java new file mode 100644 index 00000000000..82e888e5b0a --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/JsonReading.java @@ -0,0 +1,101 @@ +package com.datadog.openfeature.internal; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Reads JSON documents as generic trees for test assertions. */ +public final class JsonReading { + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + + private JsonReading() {} + + /** Reads a JSON document. Integral numbers are read as {@link Long}, others as {@link Double}. */ + public static Object read(final byte[] json) { + try (JsonParser parser = JSON_FACTORY.createParser(json)) { + parser.nextToken(); + return readValue(parser); + } catch (final IOException e) { + throw new UncheckedIOException(e); + } + } + + public static Object read(final String json) { + return read(json.getBytes(UTF_8)); + } + + @SuppressWarnings("unchecked") + public static Map readObject(final byte[] json) { + return (Map) read(json); + } + + @SuppressWarnings("unchecked") + public static Map readObject(final String json) { + return (Map) read(json); + } + + /** Reads a JSON object with all numbers as {@link Double}, like Moshi's generic adapters. */ + @SuppressWarnings("unchecked") + public static Map readObjectWithDoubles(final byte[] json) { + return (Map) toDoubles(read(json)); + } + + private static Object toDoubles(final Object value) { + if (value instanceof Long) { + return ((Long) value).doubleValue(); + } + if (value instanceof Map) { + final Map map = new LinkedHashMap<>(); + ((Map) value).forEach((k, v) -> map.put((String) k, toDoubles(v))); + return map; + } + if (value instanceof List) { + final List list = new ArrayList<>(); + ((List) value).forEach(v -> list.add(toDoubles(v))); + return list; + } + return value; + } + + private static Object readValue(final JsonParser parser) throws IOException { + final JsonToken token = parser.currentToken(); + switch (token) { + case START_OBJECT: + final Map object = new LinkedHashMap<>(); + while (parser.nextToken() == JsonToken.FIELD_NAME) { + final String name = parser.currentName(); + parser.nextToken(); + object.put(name, readValue(parser)); + } + return object; + case START_ARRAY: + final List array = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + array.add(readValue(parser)); + } + return array; + case VALUE_STRING: + return parser.getText(); + case VALUE_NUMBER_INT: + return parser.getLongValue(); + case VALUE_NUMBER_FLOAT: + return parser.getDoubleValue(); + case VALUE_TRUE: + return Boolean.TRUE; + case VALUE_FALSE: + return Boolean.FALSE; + case VALUE_NULL: + return null; + default: + throw new IOException("Unexpected token " + token); + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/LocalHttpServer.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/LocalHttpServer.java new file mode 100644 index 00000000000..c2d2e9dbdc6 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/LocalHttpServer.java @@ -0,0 +1,163 @@ +package com.datadog.openfeature.internal; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.zip.GZIPOutputStream; + +/** A local HTTP server replaying scripted responses and recording the received requests. */ +public final class LocalHttpServer implements AutoCloseable { + private final HttpServer server; + private final ConcurrentLinkedQueue responses = new ConcurrentLinkedQueue<>(); + private final List requests = new CopyOnWriteArrayList<>(); + private final BlockingQueue received = new LinkedBlockingQueue<>(); + private volatile Response fallback = new Response(404, null, Map.of(), false); + + public LocalHttpServer() throws IOException { + this.server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + this.server.createContext("/", this::handle); + this.server.start(); + } + + /** + * @param path the request path, with an optional query. + * @return the URI of the given path on this server. + */ + public URI uri(final String path) { + return URI.create( + "http://" + + this.server.getAddress().getHostString() + + ":" + + this.server.getAddress().getPort() + + path); + } + + /** Queues a response. */ + public LocalHttpServer enqueue(final int status, final String body, final String... headers) { + this.responses.add(response(status, body, false, headers)); + return this; + } + + /** Queues a gzip encoded response. */ + public LocalHttpServer enqueueGzip(final int status, final String body, final String... headers) { + this.responses.add(response(status, body, true, headers)); + return this; + } + + /** Sets the response returned once queued responses are exhausted. */ + public LocalHttpServer otherwise(final int status, final String body, final String... headers) { + this.fallback = response(status, body, false, headers); + return this; + } + + public List requests() { + return this.requests; + } + + public BlockingQueue received() { + return this.received; + } + + @Override + public void close() { + this.server.stop(0); + } + + private static Response response( + final int status, final String body, final boolean gzip, final String... headers) { + final Map headerMap = new java.util.HashMap<>(); + for (int i = 0; i < headers.length; i += 2) { + headerMap.put(headers[i], headers[i + 1]); + } + return new Response(status, body, headerMap, gzip); + } + + private void handle(final HttpExchange exchange) throws IOException { + final Request request = + new Request( + exchange.getRequestMethod(), + exchange.getRequestURI(), + exchange.getRequestHeaders(), + exchange.getRequestBody().readAllBytes()); + this.requests.add(request); + this.received.add(request); + Response response = this.responses.poll(); + if (response == null) { + response = this.fallback; + } + for (final Map.Entry header : response.headers.entrySet()) { + exchange.getResponseHeaders().add(header.getKey(), header.getValue()); + } + byte[] body = response.body == null ? new byte[0] : response.body.getBytes(UTF_8); + if (response.gzip) { + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + final ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(compressed)) { + gzip.write(body); + } + body = compressed.toByteArray(); + } + exchange.sendResponseHeaders(response.status, body.length == 0 ? -1 : body.length); + if (body.length > 0) { + try (OutputStream output = exchange.getResponseBody()) { + output.write(body); + } + } + exchange.close(); + } + + /** A received request. */ + public static final class Request { + public final String method; + public final URI uri; + public final Headers headers; + public final byte[] body; + + Request(final String method, final URI uri, final Headers headers, final byte[] body) { + this.method = method; + this.uri = uri; + this.headers = headers; + this.body = body; + } + + public String header(final String name) { + return this.headers.getFirst(name); + } + + public String bodyAsString() { + return new String(this.body, UTF_8); + } + } + + private static final class Response { + final int status; + final String body; + final Map headers; + final boolean gzip; + + Response( + final int status, + final String body, + final Map headers, + final boolean gzip) { + this.status = status; + this.body = body; + this.headers = headers; + this.gzip = gzip; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java new file mode 100644 index 00000000000..934f8254bd8 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -0,0 +1,168 @@ +package com.datadog.openfeature.internal.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class SettingsTest { + @Test + void looksUpConnectorThenSystemPropertiesThenEnvironment() { + final Map connector = new HashMap<>(); + final Map properties = new HashMap<>(); + final Map environment = new HashMap<>(); + final Settings settings = + new Settings(new SettingConnector(connector), properties::get, environment::get); + + assertNull(settings.env()); + environment.put("DD_ENV", "from-env"); + assertEquals("from-env", settings.env()); + properties.put("dd.env", "from-property"); + assertEquals("from-property", settings.env()); + connector.put("env", "from-agent"); + assertEquals("from-agent", settings.env()); + } + + @TableTest({ + "scenario | key | variable ", + "dotted key | 'feature.flags.enabled' | 'DD_FEATURE_FLAGS_ENABLED' ", + "dashed key | 'api-key' | 'DD_API_KEY' ", + "deeply nested key | 'flagging.evaluation.counts.enabled' | 'DD_FLAGGING_EVALUATION_COUNTS_ENABLED'" + }) + void mapsKeysToEnvironmentVariables(final String key, final String variable) { + assertEquals(variable, Settings.environmentVariableName(key)); + } + + @TableTest({ + "scenario | enabled | source | legacy | resolved ", + "default | | | | 'agentless' ", + "explicit agentless | | 'agentless' | | 'agentless' ", + "explicit remote config | | 'remote_config' | | 'remote_config'", + "normalized source | | ' Remote_Config ' | | 'remote_config'", + "unsupported source | | 'offline' | | ", + "blank source | | ' ' | | 'agentless' ", + "disabled wins over source | 'false' | 'remote_config' | | ", + "enabled keeps default | 'true' | | | 'agentless' ", + "legacy enabled selects rc | | | 'true' | 'remote_config'", + "legacy disabled disables | | | 'false' | ", + "explicit source wins over legacy | | 'agentless' | 'false' | 'agentless' ", + "invalid enabled is ignored | 'maybe' | | | 'agentless' ", + "numeric disabled | '0' | | | " + }) + void resolvesConfigurationSource( + final String enabled, final String source, final String legacy, final String resolved) { + final Map properties = new HashMap<>(); + putIfNotNull(properties, "dd.feature.flags.enabled", enabled); + putIfNotNull(properties, "dd.feature.flags.configuration.source", source); + putIfNotNull(properties, "dd.experimental.flagging.provider.enabled", legacy); + final Settings settings = new Settings(Connector.NONE, properties::get, name -> null); + + assertEquals(resolved, settings.configurationSource()); + assertEquals(resolved != null, settings.isEnabled()); + } + + @Test + void appliesDefaults() { + final Settings settings = TestSettings.of(); + + assertEquals("datadoghq.com", settings.site()); + assertNull(settings.apiKey()); + assertNull(settings.service()); + assertNull(settings.version()); + assertNull(settings.agentlessBaseUrl()); + assertEquals(30, settings.agentlessPollIntervalSeconds()); + assertEquals(5, settings.agentlessRequestTimeoutSeconds()); + assertFalse(settings.isSpanEnrichmentEnabled()); + assertTrue(settings.isEvaluationCountsEnabled()); + } + + @TableTest({ + "scenario | value | interval", + "valid | '10' | 10 ", + "zero | '0' | 30 ", + "negative | '-5' | 30 ", + "invalid | 'ten' | 30 ", + "blank | ' ' | 30 " + }) + void fallsBackToDefaultPollIntervalOnInvalidValues(final String value, final int interval) { + assertEquals( + interval, + TestSettings.of("feature.flags.configuration.source.agentless.poll.interval.seconds", value) + .agentlessPollIntervalSeconds()); + } + + @Test + void trimsAndIgnoresBlankValues() { + final Settings settings = + TestSettings.of( + "api-key", " secret ", + "site", " ", + "feature.flags.configuration.source.agentless.base.url", " "); + + assertEquals("secret", settings.apiKey()); + assertEquals("datadoghq.com", settings.site()); + assertNull(settings.agentlessBaseUrl()); + } + + @Test + void readsToggles() { + final Settings settings = + TestSettings.of( + "experimental.flagging.provider.span.enrichment.enabled", "true", + "flagging.evaluation.counts.enabled", "false"); + + assertTrue(settings.isSpanEnrichmentEnabled()); + assertFalse(settings.isEvaluationCountsEnabled()); + } + + private static void putIfNotNull( + final Map map, final String key, final String value) { + if (value != null) { + map.put(key, value); + } + } + + /** A connector only providing settings. */ + private static final class SettingConnector implements Connector { + private final Map settings; + + SettingConnector(final Map settings) { + this.settings = settings; + } + + @Override + public String setting(final String key) { + return this.settings.get(key); + } + + @Override + public ConfigurationSource remoteConfiguration() { + return null; + } + + @Override + public EventTransport eventProxy() { + return null; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java new file mode 100644 index 00000000000..32caeb81da1 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java @@ -0,0 +1,31 @@ +package com.datadog.openfeature.internal.config; + +import com.datadog.openfeature.internal.connector.Connector; +import java.util.HashMap; +import java.util.Map; + +/** Builds {@link Settings} from explicit values, ignoring the JVM system properties and env. */ +public final class TestSettings { + private TestSettings() {} + + /** + * @param keyValues alternating setting keys, without the {@code dd.} prefix, and values. + * @return the settings backed by the given values only. + */ + public static Settings of(final String... keyValues) { + return of(Connector.NONE, keyValues); + } + + /** + * @param connector the connector to look settings up from first. + * @param keyValues alternating setting keys, without the {@code dd.} prefix, and values. + * @return the settings backed by the connector and the given values only. + */ + public static Settings of(final Connector connector, final String... keyValues) { + final Map properties = new HashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + properties.put("dd." + keyValues[i], keyValues[i + 1]); + } + return new Settings(connector, properties::get, name -> null); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java new file mode 100644 index 00000000000..6e624d7352c --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java @@ -0,0 +1,461 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class CdnConfigurationSourceTest { + private static final long POLL_INTERVAL_MILLIS = 30_000; + private static final String CONFIG = + "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + "{\"createdAt\":\"2024-04-17T19:40:53.716Z\",\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}}}}"; + private static final String RAW_UFC = + "{\"createdAt\":\"2024-04-17T19:40:53.716Z\",\"environment\":{\"name\":\"Test\"},\"flags\":{}}"; + + private LocalHttpServer server; + private final List dispatched = new CopyOnWriteArrayList<>(); + private final List sleeps = new CopyOnWriteArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + this.server = new LocalHttpServer(); + } + + @AfterEach + void tearDown() { + this.server.close(); + } + + @Test + void derivesDatadogUfcCdnEndpointFromSiteAndEnv() { + final URI endpoint = + CdnConfigurationSource.endpoint(TestSettings.of("site", "datadoghq.eu", "env", "prod")); + + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.eu/api/v2/feature-flagging/config/rules-based/server?dd_env=prod", + endpoint.toString()); + } + + @Test + void derivesDatadogUfcCdnEndpointWithoutEnv() { + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", + CdnConfigurationSource.endpoint(TestSettings.of()).toString()); + } + + @Test + void encodesEnvInDatadogUfcCdnEndpoint() { + assertEquals( + "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server?dd_env=a+b%26c", + CdnConfigurationSource.endpoint(TestSettings.of("env", "a b&c")).toString()); + } + + @TableTest({ + "scenario | baseUrl | endpoint ", + "host only | 'http://localhost:8080' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server'", + "root path | 'http://localhost:8080/' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server'", + "custom path | 'https://proxy.example/ufc.json' | 'https://proxy.example/ufc.json' ", + "trimmed | ' https://proxy.example/ufc ' | 'https://proxy.example/ufc' " + }) + void resolvesConfiguredAgentlessBaseUrl(final String baseUrl, final String endpoint) { + assertEquals( + endpoint, + CdnConfigurationSource.endpoint( + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + baseUrl, + "env", + "prod")) + .toString()); + } + + @TableTest({ + "scenario | baseUrl ", + "no scheme | 'localhost' ", + "relative path | 'relative/ufc'", + "invalid | 'http://[::1' " + }) + void rejectsInvalidConfiguredAgentlessBaseUrl(final String baseUrl) { + final Settings settings = + TestSettings.of("feature.flags.configuration.source.agentless.base.url", baseUrl); + + assertThrows(IllegalArgumentException.class, () -> CdnConfigurationSource.endpoint(settings)); + } + + @Test + void customEndpointDoesNotSendApiKey() throws Exception { + this.server.enqueue(200, CONFIG); + final CdnConfigurationSource source = + new CdnConfigurationSource( + TestSettings.of( + "feature.flags.configuration.source.agentless.base.url", + this.server.uri("/").toString(), + "api-key", + "secret"), + new RuntimeServices(HealthMetrics.NOOP)); + try { + source.start(this.dispatched::add); + } finally { + source.close(); + } + + assertEquals(1, this.dispatched.size()); + assertNull(this.server.requests().get(0).header("DD-API-KEY")); + } + + @Test + void sendsApiKeyAndMetadataHeaders() throws Exception { + this.server.enqueue(200, CONFIG); + + assertTrue(source("secret").pollOnce()); + + final LocalHttpServer.Request request = this.server.requests().get(0); + assertEquals("GET", request.method); + assertEquals("secret", request.header("DD-API-KEY")); + assertEquals("gzip", request.header("Accept-Encoding")); + assertEquals("java", request.header("Datadog-Meta-Lang")); + assertNull(request.header("If-None-Match")); + } + + @Test + void appliesJsonApiConfiguration() { + this.server.enqueue(200, CONFIG, "ETag", "\"v1\""); + + assertTrue(source().pollOnce()); + + assertEquals(1, this.dispatched.size()); + assertEquals("Test", this.dispatched.get(0).environment.name); + } + + @Test + void decodesGzipResponses() { + this.server.enqueueGzip(200, CONFIG); + + assertTrue(source().pollOnce()); + + assertEquals(1, this.dispatched.size()); + } + + @Test + void keepsLastKnownGoodOnRawUfcAndMalformedPayloads() { + this.server.enqueue(200, CONFIG).enqueue(200, RAW_UFC).enqueue(200, "{not json"); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertFalse(source.pollOnce()); + assertFalse(source.pollOnce()); + + assertEquals(1, this.dispatched.size()); + } + + @Test + void usesEtagAndSkipsDispatchOnUnchangedConfig() { + this.server.enqueue(200, CONFIG, "ETag", "\"v1\"").enqueue(304, null); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertTrue(source.pollOnce()); + + assertEquals(1, this.dispatched.size()); + assertEquals("\"v1\"", this.server.requests().get(1).header("If-None-Match")); + } + + @Test + void ignoresBlankEtagAndClearsPreviousEtag() { + this.server + .enqueue(200, CONFIG, "ETag", "\"v1\"") + .enqueue(200, CONFIG) + .enqueue(200, CONFIG, "ETag", " ") + .enqueue(200, CONFIG); + final CdnConfigurationSource source = source(); + + for (int i = 0; i < 4; i++) { + assertTrue(source.pollOnce()); + } + + assertEquals("\"v1\"", this.server.requests().get(1).header("If-None-Match")); + assertNull(this.server.requests().get(2).header("If-None-Match")); + assertNull(this.server.requests().get(3).header("If-None-Match")); + } + + @Test + void coldNotModifiedDoesNotEstablishEtag() { + this.server.enqueue(304, null, "ETag", "\"v1\"").enqueue(200, CONFIG); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertTrue(source.pollOnce()); + + assertNull(this.server.requests().get(1).header("If-None-Match")); + assertEquals(1, this.dispatched.size()); + } + + @Test + void failedDispatchDoesNotAdvanceEtag() { + this.server.enqueue(200, CONFIG, "ETag", "\"v1\"").enqueue(200, CONFIG, "ETag", "\"v2\""); + final CdnConfigurationSource source = source(); + final RuntimeException failure = new IllegalStateException("listener failure"); + source.setListenerForTest( + configuration -> { + throw failure; + }); + + assertThrows(IllegalStateException.class, source::pollOnce); + source.setListenerForTest(this.dispatched::add); + assertTrue(source.pollOnce()); + + assertNull(this.server.requests().get(1).header("If-None-Match")); + } + + @TableTest({ + "scenario | status", + "unauthorized | 401 ", + "forbidden | 403 ", + "not found | 404 ", + "no content | 204 " + }) + void keepsLastKnownGoodOnNonOkResponses(final int status) { + this.server.enqueue(200, CONFIG).enqueue(status, null); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertFalse(source.pollOnce()); + + assertEquals(1, this.dispatched.size()); + assertEquals(2, this.server.requests().size()); + } + + @TableTest({ + "scenario | status", + "client timeout | 408 ", + "too many requests | 429 ", + "server error | 503 " + }) + void retriesRetryableStatusBeforeApplyingConfig(final int status) { + this.server.enqueue(status, null).enqueue(status, null).enqueue(200, CONFIG); + + assertTrue(source().pollOnce()); + + assertEquals(3, this.server.requests().size()); + assertEquals(1, this.dispatched.size()); + assertEquals(2, this.sleeps.size()); + } + + @Test + void stopsAfterThreeAttemptsOnRetryableStatus() { + this.server.otherwise(500, null); + + assertFalse(source().pollOnce()); + + assertEquals(CdnConfigurationSource.MAX_ATTEMPTS, this.server.requests().size()); + assertTrue(this.dispatched.isEmpty()); + } + + @Test + void retriesServerErrorThenKeepsColdStateOnNotModified() { + this.server.enqueue(500, null).enqueue(304, null); + + assertTrue(source().pollOnce()); + + assertEquals(2, this.server.requests().size()); + assertTrue(this.dispatched.isEmpty()); + } + + @Test + void retriesIoFailuresUntilAttemptsAreExhausted() throws Exception { + final URI unreachable; + try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + unreachable = URI.create("http://127.0.0.1:" + socket.getLocalPort() + "/ufc"); + } + final CdnConfigurationSource source = + source(unreachable, null, mock(ScheduledExecutorService.class)); + + assertFalse(source.pollOnce()); + + assertEquals(CdnConfigurationSource.MAX_ATTEMPTS - 1, this.sleeps.size()); + } + + @Test + void usesIntervalAwareRetryBackoff() { + this.server.enqueue(503, null).enqueue(503, null).enqueue(200, CONFIG); + + assertTrue(source().pollOnce()); + + assertEquals( + List.of( + CdnConfigurationSource.retryDelayMillis(POLL_INTERVAL_MILLIS, 1, 1.0), + CdnConfigurationSource.retryDelayMillis(POLL_INTERVAL_MILLIS, 2, 1.0)), + this.sleeps); + } + + @TableTest({ + "scenario | pollIntervalMillis | attempt | jitter | delay", + "first clamped low | 1000 | 1 | 1.0 | 2000 ", + "first proportional | 30000 | 1 | 1.0 | 5000 ", + "first clamped high | 600000 | 1 | 1.0 | 10000", + "second clamped low | 1000 | 2 | 1.0 | 5000 ", + "second proportional | 30000 | 2 | 1.0 | 10000", + "second clamped high | 600000 | 2 | 1.0 | 30000", + "jitter down | 30000 | 1 | 0.8 | 4000 ", + "jitter up | 30000 | 2 | 1.2 | 12000" + }) + void clampsAndJittersRetryBackoff( + final long pollIntervalMillis, final int attempt, final double jitter, final long delay) { + assertEquals( + delay, CdnConfigurationSource.retryDelayMillis(pollIntervalMillis, attempt, jitter)); + } + + @Test + void rejectsUnsupportedRetryAttempt() { + assertThrows( + IllegalArgumentException.class, + () -> CdnConfigurationSource.retryDelayMillis(POLL_INTERVAL_MILLIS, 3, 1.0)); + } + + @Test + void startCompletesFirstPollAndCloseCancelsScheduledPoll() { + this.server.enqueue(200, CONFIG); + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + final ScheduledFuture future = mock(ScheduledFuture.class); + doReturn(future) + .when(executor) + .scheduleWithFixedDelay( + any(Runnable.class), + eq(POLL_INTERVAL_MILLIS), + eq(POLL_INTERVAL_MILLIS), + eq(MILLISECONDS)); + final CdnConfigurationSource source = source(this.server.uri("/ufc"), null, executor); + + source.start(this.dispatched::add); + source.start(this.dispatched::add); + + assertEquals(1, this.dispatched.size()); + assertEquals(1, this.server.requests().size()); + verify(executor, times(1)) + .scheduleWithFixedDelay( + any(Runnable.class), + eq(POLL_INTERVAL_MILLIS), + eq(POLL_INTERVAL_MILLIS), + eq(MILLISECONDS)); + + source.close(); + verify(future).cancel(true); + verify(executor).shutdownNow(); + assertFalse(source.pollOnce()); + } + + @Test + void startSurvivesListenerFailure() { + this.server.enqueue(200, CONFIG); + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + final CdnConfigurationSource source = source(this.server.uri("/ufc"), null, executor); + + source.start( + configuration -> { + throw new IllegalStateException("listener failure"); + }); + + verify(executor) + .scheduleWithFixedDelay(any(Runnable.class), any(Long.class), any(Long.class), any()); + } + + @Test + void startAfterCloseDoesNotPoll() { + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + final CdnConfigurationSource source = source(this.server.uri("/ufc"), null, executor); + + source.close(); + source.start(this.dispatched::add); + + assertTrue(this.server.requests().isEmpty()); + verify(executor, never()) + .scheduleWithFixedDelay(any(Runnable.class), any(Long.class), any(Long.class), any()); + } + + @Test + void closeInterruptsRetryBackoff() throws Exception { + this.server.otherwise(503, null); + final CdnConfigurationSource source = + new CdnConfigurationSource( + this.server.uri("/ufc"), + null, + TimeUnit.MINUTES.toMillis(10), + Duration.ofSeconds(5), + HttpClient.newHttpClient(), + mock(ScheduledExecutorService.class), + MILLISECONDS::sleep, + () -> 1.0); + final boolean[] result = {true}; + final Thread poller = new Thread(() -> result[0] = source.pollOnce()); + poller.start(); + this.server.received().poll(5, SECONDS); + + source.close(); + poller.join(SECONDS.toMillis(5)); + + assertFalse(poller.isAlive(), "close must interrupt the retry backoff"); + assertFalse(result[0]); + assertEquals(1, this.server.requests().size()); + } + + private CdnConfigurationSource source() { + return source(null); + } + + private CdnConfigurationSource source(final String apiKey) { + final CdnConfigurationSource source = + source(this.server.uri("/ufc"), apiKey, mock(ScheduledExecutorService.class)); + source.setListenerForTest(this.dispatched::add); + return source; + } + + private CdnConfigurationSource source( + final URI endpoint, final String apiKey, final ScheduledExecutorService executor) { + final CdnConfigurationSource source = + new CdnConfigurationSource( + endpoint, + apiKey, + POLL_INTERVAL_MILLIS, + Duration.ofSeconds(5), + HttpClient.newHttpClient(), + executor, + this.sleeps::add, + () -> 1.0); + source.setListenerForTest(this.dispatched::add); + return source; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java new file mode 100644 index 00000000000..783e2ba3b88 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java @@ -0,0 +1,120 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class DirectIntakeTransportTest { + private LocalHttpServer server; + private final List sleeps = new CopyOnWriteArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + this.server = new LocalHttpServer(); + } + + @AfterEach + void tearDown() { + this.server.close(); + } + + @Test + void requiresAnApiKey() { + assertNull(DirectIntakeTransport.create(TestSettings.of())); + assertNotNull(DirectIntakeTransport.create(TestSettings.of("api-key", "secret"))); + } + + @Test + void postsJsonWithApiKeyAndTraceHeaders() throws Exception { + this.server.enqueue(202, null); + + transport().post("exposures", "{\"a\":1}".getBytes(UTF_8)); + + final LocalHttpServer.Request request = this.server.requests().get(0); + assertEquals("POST", request.method); + assertEquals("/api/v2/exposures", request.uri.getPath()); + assertEquals("{\"a\":1}", request.bodyAsString()); + assertEquals("application/json", request.header("Content-Type")); + assertEquals("secret", request.header("dd-api-key")); + assertNotNull(request.header("x-datadog-trace-id")); + assertEquals(request.header("x-datadog-trace-id"), request.header("x-datadog-parent-id")); + assertEquals("java", request.header("Datadog-Meta-Lang")); + assertNull(request.header("X-Datadog-EVP-Subdomain")); + } + + @TableTest({ + "scenario | status", + "server error | 500 ", + "unavailable | 503 ", + "too many requests | 429 " + }) + void retriesRetryableStatus(final int status) throws Exception { + this.server.enqueue(status, null).enqueue(status, null).enqueue(200, null); + + transport().post("flagevaluation", new byte[] {'{', '}'}); + + assertEquals(3, this.server.requests().size()); + assertEquals(List.of(100L, 200L), this.sleeps); + } + + @TableTest({ + "scenario | status", + "bad request | 400 ", + "forbidden | 403 ", + "not found | 404 ", + "redirect | 302 " + }) + void doesNotRetryOtherFailures(final int status) { + this.server.otherwise(status, null); + + assertThrows(IOException.class, () -> transport().post("exposures", new byte[] {'{', '}'})); + + assertEquals(1, this.server.requests().size()); + } + + @Test + void givesUpAfterMaxRetries() { + this.server.otherwise(503, null); + + assertThrows(IOException.class, () -> transport().post("exposures", new byte[] {'{', '}'})); + + assertEquals(DirectIntakeTransport.MAX_RETRIES + 1, this.server.requests().size()); + assertEquals(DirectIntakeTransport.MAX_RETRIES, this.sleeps.size()); + } + + @Test + void retriesConnectionFailures() throws Exception { + final URI unreachable; + try (ServerSocket socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + unreachable = URI.create("http://127.0.0.1:" + socket.getLocalPort() + "/api/v2/"); + } + final DirectIntakeTransport transport = + new DirectIntakeTransport( + unreachable, "secret", HttpClient.newHttpClient(), this.sleeps::add); + + assertThrows(IOException.class, () -> transport.post("exposures", new byte[] {'{', '}'})); + + assertEquals(DirectIntakeTransport.MAX_RETRIES, this.sleeps.size()); + } + + private DirectIntakeTransport transport() { + return new DirectIntakeTransport( + this.server.uri("/api/v2/"), "secret", HttpClient.newHttpClient(), this.sleeps::add); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java new file mode 100644 index 00000000000..e7b55abb572 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java @@ -0,0 +1,52 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; +import com.datadog.openfeature.internal.connector.EventTransport; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class FallbackTransportTest { + private static final byte[] PAYLOAD = "{}".getBytes(UTF_8); + + private final EventTransport proxy = mock(EventTransport.class); + private final EventTransport direct = mock(EventTransport.class); + private final FallbackTransport transport = new FallbackTransport(this.proxy, this.direct); + + @Test + void postsThroughTheProxy() throws IOException { + this.transport.post("exposures", PAYLOAD); + + verify(this.proxy).post("exposures", PAYLOAD); + verify(this.direct, never()).post("exposures", PAYLOAD); + } + + @Test + void switchesToDirectIntakeForGoodWhenTheProxyIsUnavailable() throws IOException { + doThrow(new EventProxyUnavailableException("unavailable")) + .when(this.proxy) + .post("exposures", PAYLOAD); + + this.transport.post("exposures", PAYLOAD); + this.transport.post("exposures", PAYLOAD); + + verify(this.proxy, times(1)).post("exposures", PAYLOAD); + verify(this.direct, times(2)).post("exposures", PAYLOAD); + } + + @Test + void doesNotReplayAmbiguousProxyFailures() throws IOException { + doThrow(new IOException("timeout")).when(this.proxy).post("exposures", PAYLOAD); + + assertThrows(IOException.class, () -> this.transport.post("exposures", PAYLOAD)); + + verify(this.direct, never()).post("exposures", PAYLOAD); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java new file mode 100644 index 00000000000..3595fdc6dc4 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java @@ -0,0 +1,299 @@ +package com.datadog.openfeature.internal.exposure; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +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 com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class ExposurePipelineTest { + private static final long TIMEOUT_NANOS = SECONDS.toNanos(5); + + private final List> requests = new CopyOnWriteArrayList<>(); + private final AtomicInteger posts = new AtomicInteger(); + private volatile int failingPost = -1; + + private final EventTransport transport = + (route, json) -> { + assertEquals("exposures", route); + if (this.posts.incrementAndGet() == this.failingPost) { + throw new IOException("ambiguous timeout"); + } + this.requests.add(JsonReading.readObject(json)); + }; + + @TableTest({ + "service | env | version", + " | | ", + "'test-service' | 'test' | '23' ", + "'test-service' | | '23' ", + "'test-service' | 'test' | " + }) + void testExposureEventWrites(final String service, final String env, final String version) + throws Exception { + final Map context = new HashMap<>(); + putIfNotNull(context, "service", service); + putIfNotNull(context, "env", env); + putIfNotNull(context, "version", version); + final List exposures = buildExposures(5); + + try (ExposurePipeline pipeline = pipeline(context)) { + pipeline.start(); + exposures.forEach(pipeline); + + eventually(() -> allExposures().size() == exposures.size()); + } + + for (final Map request : this.requests) { + assertEquals(context, request.get("context")); + } + assertEquals(keys(exposures), exposureKeys(allExposures())); + } + + @Test + void serializesExposureWireShape() throws Exception { + final ExposureEvent exposure = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", singletonMap("tier", "gold")), + 7); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(exposure); + + eventually(() -> !allExposures().isEmpty()); + } + + final Map json = allExposures().get(0); + assertEquals(1234L, json.get("timestamp")); + assertEquals(singletonMap("key", "allocation"), json.get("allocation")); + assertEquals(singletonMap("key", "flag"), json.get("flag")); + assertEquals(singletonMap("key", "variant"), json.get("variant")); + final Map subject = new HashMap<>(); + subject.put("id", "subject"); + subject.put("attributes", singletonMap("tier", "gold")); + assertEquals(subject, json.get("subject")); + assertEquals(7L, json.get("serial_id")); + } + + @Test + void testLruCache() throws Exception { + final List exposures = buildExposures(6); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + // populating the cache + exposures.forEach(pipeline); + + // all events are written + eventually(() -> allExposures().size() == exposures.size()); + + // publishing duplicate events + exposures.forEach(pipeline); + + // no events are written + MILLISECONDS.sleep(300); // wait until a flush happens + assertEquals(exposures.size(), allExposures().size()); + + // a new event is generated + pipeline.accept(buildExposure()); + + // oldest event is evicted and the new one is submitted + eventually(() -> allExposures().size() == exposures.size() + 1); + } + } + + @Test + void testHighLoadScenario() throws Exception { + final int exposuresPerThread = 100; + final int threads = Runtime.getRuntime().availableProcessors(); + final ExecutorService executor = Executors.newFixedThreadPool(threads); + final List exposures = buildExposures(threads * exposuresPerThread); + final CountDownLatch latch = new CountDownLatch(1); + + try (ExposurePipeline pipeline = + new ExposurePipeline( + ExposurePipeline.DEFAULT_CAPACITY, + 100, + MILLISECONDS, + this.transport, + emptyMap(), + new RuntimeServices(HealthMetrics.NOOP))) { + pipeline.start(); + final List> futures = new ArrayList<>(); + for (int index = 0; index < exposures.size(); index += exposuresPerThread) { + final List partition = + exposures.subList(index, Math.min(index + exposuresPerThread, exposures.size())); + futures.add( + executor.submit( + () -> { + latch.await(); + partition.forEach(pipeline); + return true; + })); + } + latch.countDown(); // start threads + for (final Future future : futures) { + assertTrue(future.get()); // wait for all threads to finish + } + + eventually(() -> allExposures().size() == exposures.size()); + assertEquals(keys(exposures), exposureKeys(allExposures())); + } finally { + executor.shutdownNow(); + } + } + + @Test + void testAmbiguousExposureBatchIsNotRetried() throws Exception { + this.failingPost = 1; + final List exposures = buildExposures(2); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(exposures.get(0)); + eventually(() -> this.posts.get() == 1); + MILLISECONDS.sleep(300); + assertEquals(1, this.posts.get()); + + pipeline.accept(exposures.get(1)); + eventually(() -> !allExposures().isEmpty()); + } + + assertEquals(keys(exposures.subList(1, 2)), exposureKeys(allExposures())); + } + + @Test + void testNonFiniteAttributesDoNotPoisonFollowingExposures() throws Exception { + final ExposureEvent invalid = buildExposure(singletonMap("invalid", Double.NaN)); + final ExposureEvent valid = buildExposure(); + + try (ExposurePipeline pipeline = pipeline(emptyMap())) { + pipeline.start(); + pipeline.accept(invalid); + pipeline.accept(valid); + + eventually(() -> allExposures().size() == 2); + } + + assertEquals(keys(List.of(invalid, valid)), exposureKeys(allExposures())); + } + + @Test + void closeStopsTheSerializer() throws Exception { + final ExposurePipeline pipeline = pipeline(emptyMap()); + pipeline.start(); + assertTrue(pipeline.isSerializerThreadAlive()); + + pipeline.close(); + + eventually(() -> !pipeline.isSerializerThreadAlive()); + assertFalse(pipeline.isSerializerThreadAlive()); + } + + private ExposurePipeline pipeline(final Map context) { + return new ExposurePipeline( + 1 << 4, + 100, + MILLISECONDS, + this.transport, + context, + new RuntimeServices(HealthMetrics.NOOP)); + } + + @SuppressWarnings("unchecked") + private List> allExposures() { + final List> exposures = new ArrayList<>(); + for (final Map request : this.requests) { + exposures.addAll((List>) request.get("exposures")); + } + return exposures; + } + + private static Set keys(final List exposures) { + final Set keys = new HashSet<>(); + for (final ExposureEvent exposure : exposures) { + keys.add(exposure.flag.key + "/" + exposure.subject.id); + } + return keys; + } + + @SuppressWarnings("unchecked") + private static Set exposureKeys(final List> exposures) { + final Set keys = new HashSet<>(); + for (final Map exposure : exposures) { + keys.add( + ((Map) exposure.get("flag")).get("key") + + "/" + + ((Map) exposure.get("subject")).get("id")); + } + return keys; + } + + private static List buildExposures(final int size) { + final List exposures = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + exposures.add(buildExposure()); + } + return exposures; + } + + private static ExposureEvent buildExposure() { + return buildExposure(emptyMap()); + } + + private static ExposureEvent buildExposure(final Map attributes) { + return new ExposureEvent( + System.currentTimeMillis(), + new Allocation(UUID.randomUUID().toString()), + new Flag(UUID.randomUUID().toString()), + new Variant(UUID.randomUUID().toString()), + new Subject(UUID.randomUUID().toString(), attributes), + null); + } + + private static void putIfNotNull( + final Map map, final String key, final String value) { + if (value != null) { + map.put(key, value); + } + } + + private static void eventually(final BooleanSupplier condition) throws InterruptedException { + final long deadline = System.nanoTime() + TIMEOUT_NANOS; + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Condition not met in time"); + } + MILLISECONDS.sleep(10); + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java new file mode 100644 index 00000000000..c4a61ba23a7 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java @@ -0,0 +1,62 @@ +package com.datadog.openfeature.internal.exposure; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.JsonReading; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExposuresRequestTest { + @Test + void serializesSerialIdUnderTheIntakeWireKey() { + assertTrue(exposureJson(340132).contains("\"serial_id\":340132")); + } + + @Test + void serializesSerialIdZeroRatherThanOmittingIt() { + assertTrue(exposureJson(0).contains("\"serial_id\":0")); + } + + @Test + void omitsSerialIdKeyWhenAbsent() { + assertFalse(exposureJson(null).contains("serial_id")); + } + + @Test + void serializesContextAndSubject() { + final ExposureEvent event = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject(null, singletonMap("score", 42)), + null); + + final Map json = + JsonReading.readObject( + new ExposuresRequest(singletonMap("service", "svc"), singletonList(event)).serialize()); + + assertEquals(singletonMap("service", "svc"), json.get("context")); + final Map exposure = (Map) ((java.util.List) json.get("exposures")).get(0); + assertEquals(singletonMap("attributes", singletonMap("score", 42L)), exposure.get("subject")); + } + + private static String exposureJson(final Integer serialId) { + final ExposureEvent event = + new ExposureEvent( + 1234L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", emptyMap()), + serialId); + return new String(new ExposuresRequest(emptyMap(), singletonList(event)).serialize(), UTF_8); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java similarity index 96% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java index 7d43c7a8b51..f48c3e32e0a 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/LRUExposureCacheTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.exposure; import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,11 +7,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import datadog.trace.api.featureflag.exposure.Allocation; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.Flag; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.exposure.Variant; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; @@ -194,14 +189,16 @@ void testKeyEqualityWithNullValues() { new Allocation("allocation"), new Flag(null), new Variant("variant"), - new Subject(null, emptyMap())); + new Subject(null, emptyMap()), + null); ExposureEvent event2 = new ExposureEvent( System.currentTimeMillis(), new Allocation("allocation"), new Flag(null), new Variant("variant"), - new Subject(null, emptyMap())); + new Subject(null, emptyMap()), + null); cache.add(event1); boolean duplicateAdded = cache.add(event2); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java similarity index 98% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java index 06faeadefea..811cd0eaa08 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationAggregatorTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -207,7 +206,7 @@ void capSizingUsesNamedScaleConstants() { void flagEvalEventDoesNotCarryReason() { final boolean hasReasonField = Arrays.stream( - datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent.class + com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent.class .getDeclaredFields()) .anyMatch(field -> field.getName().equals("reason")); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java similarity index 94% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java index d4ca517fffd..389e2b3c30f 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationPayloadsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.flagevaluation; import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -7,10 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import java.lang.reflect.Type; +import com.datadog.openfeature.internal.JsonReading; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -21,13 +18,6 @@ class FlagEvaluationPayloadsTest { private static final long EVAL_MS = 1_760_000_000_000L; private static final Map CONTEXT = context(); - private static final JsonAdapter> JSON_MAP; - - static { - final Moshi moshi = new Moshi.Builder().build(); - final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); - JSON_MAP = moshi.adapter(type); - } @Test void fullTierPayloadUsesWorkerWireShape() throws Exception { @@ -329,19 +319,6 @@ void emptyEventListProducesNoPayloads() { assertEquals(0, payloads.degradedPayloadLimit); } - @Test - void requestDtoStoresContextAndEvents() { - final List events = - java.util.Collections.singletonList( - event("dto-flag", "on", "alloc1", "user-1", 1, emptyMap())); - - final FlagEvaluationPayloads.FlagEvaluationsRequest request = - new FlagEvaluationPayloads.FlagEvaluationsRequest(CONTEXT, events); - - assertEquals(CONTEXT, request.context); - assertEquals(events, request.flagEvaluations); - } - @Test void degradingEventWithMissingOptionalFieldsKeepsOptionalObjectsAbsent() { final FlagEvaluationPayloads.FlagEvaluationEvent degraded = @@ -416,7 +393,7 @@ private static Map firstPayload( } private static Map parse(final byte[] body) throws Exception { - return JSON_MAP.fromJson(new String(body, java.nio.charset.StandardCharsets.UTF_8)); + return JsonReading.readObjectWithDoubles(body); } @SuppressWarnings("unchecked") diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java new file mode 100644 index 00000000000..269c37f3b48 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java @@ -0,0 +1,484 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.buildTestWriter; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.context; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.event; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.eventForFlag; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.flushAndCapture; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.flushAndCaptureJson; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.readJson; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.repeat; +import static com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.simpleEvent; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.EventQueue; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.CapturingTransport; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.RecordingMetrics; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class FlagEvaluationPipelineTest { + + private final CapturingTransport transport = new CapturingTransport(); + private final RecordingMetrics metrics = new RecordingMetrics(); + + private FlagEvaluationPipeline writer( + final int capacity, final long flushInterval, final TimeUnit unit) { + return new FlagEvaluationPipeline( + capacity, + flushInterval, + unit, + this.transport, + context(), + new RuntimeServices(this.metrics)); + } + + @Test + void degradedCapOverflowTelemetryIsEmittedOnFlush() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.addDroppedDegradedOverflowForTest(3); + setup.handler.flush(); + + assertEquals( + 3, + setup.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_DEGRADED_CAP)); + } + + @Test + void queueOverflowIncrementsObservableDropCounter() { + final FlagEvaluationPipeline writer = writer(2, 10L, TimeUnit.SECONDS); + + for (int i = 0; i < 100; i++) { + writer.enqueue(simpleEvent("of-flag", "on")); + } + + assertTrue(writer.droppedQueueOverflow() > 0); + final long queueDrops = writer.droppedQueueOverflow(); + writer.flushForTest(); + assertEquals( + queueDrops, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_QUEUE_OVERFLOW)); + } + + @Test + void enqueueAfterCloseIsDroppedAndCounted() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.close(); + writer.enqueue(simpleEvent("closed-flag", "on")); + + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void closeSweepsAndCountsEventsLeftInTheQueue() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + // The worker is never started, so nothing drains these; close() must account for them rather + // than leave them silently stranded. Stands in for the narrow window where a lock-free + // producer offers after the worker's final drain. + writer.enqueue(simpleEvent("residual-flag-1", "on")); + writer.enqueue(simpleEvent("residual-flag-2", "on")); + writer.close(); + + assertEquals( + 2, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_CLOSED)); + assertNull(writer.pollQueuedEventForTest()); + } + + @Test + void enqueueIgnoresNullEvent() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.enqueue(null); + + assertNull(writer.pollQueuedEventForTest()); + assertEquals(0, writer.droppedQueueOverflow()); + } + + @Test + void enqueueDoesNotAggregateOnTheCallingThread() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.enqueue(simpleEvent("g2-flag", "on")); + writer.enqueue(simpleEvent("g2-flag", "on")); + + assertEquals(0, writer.aggregatorFullTierSizeForTest()); + assertEquals(0, writer.droppedQueueOverflow()); + } + + @Test + void flushIfNecessarySkipsEmptyStateAndWaitsForInterval() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.flushIfNecessary(); + setup.handler.add(simpleEvent("pending-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flushIfNecessary(); + + assertEquals(1, setup.handler.fullTierSizeForTest()); + } + + @Test + void flushIfNecessaryDoesNotReturnEarlyWhenOnlyQueueDropsArePending() { + final AtomicLong queueDrops = new AtomicLong(1); + final FlagEvaluationPipeline.FlagEvaluationSerializingHandler handler = + new FlagEvaluationPipeline.FlagEvaluationSerializingHandler( + this.transport, + new EventQueue<>(16), + Long.MAX_VALUE, + TimeUnit.NANOSECONDS, + context(), + queueDrops, + new ConcurrentHashMap<>(), + FlagEvaluationPipeline.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES, + new RuntimeServices(this.metrics)); + + handler.flushIfNecessary(); + + assertEquals(1, queueDrops.get()); + } + + @Test + void degradedBucketsAreSerializedWithoutTargetingKeyOrContext() throws Exception { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.addDegradedBucketForTest("degraded-flag", "on", "alloc1", null, 1000L); + + final Map json = flushAndCaptureJson(setup); + + final Map ev = eventForFlag(json, "degraded-flag"); + assertNotNull(ev); + assertNull(ev.get("targeting_key")); + assertNull(ev.get("context")); + } + + @Test + void testHandlerCanSimulateAndClearDegradedTierAtCap() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.simulateDegradedTierAtCap(); + setup.handler.clearAggregationForTest(); + setup.handler.add(simpleEvent("after-clear", "on")); + setup.handler.drainAndAggregate(); + + assertEquals(1, setup.handler.fullTierSizeForTest()); + } + + @Test + void payloadLimitDropsAreCountedOnFlush() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(128); + setup.handler.add(event(repeat('f', 512), "on", "alloc1", "user-1", 1000L, emptyMap())); + + setup.handler.drainAndAggregate(); + setup.handler.flush(); + + assertEquals( + 1, + setup.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_PAYLOAD_LIMIT)); + } + + @Test + void finalFlushRunsWithoutTheInterruptFlagSet() throws Exception { + // close() interrupts the worker to break it out of poll(). The final flush does socket I/O, + // which fails fast on an interrupted thread, so the flag must be clear by the time the + // transport is called. + final CountDownLatch posted = new CountDownLatch(1); + final boolean[] interruptedDuringPost = {true}; + this.transport.onPost = + () -> { + interruptedDuringPost[0] = Thread.currentThread().isInterrupted(); + posted.countDown(); + }; + final FlagEvaluationPipeline writer = writer(64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS); + + writer.start(); + writer.enqueue(simpleEvent("interrupt-flag", "on")); + writer.close(); + + assertTrue(posted.await(5, TimeUnit.SECONDS)); + assertFalse( + interruptedDuringPost[0], + "final flush must not run on an interrupted thread; the drained rows would be lost"); + } + + @Test + void closeDrainsAndFinalFlushesQueuedEvents() throws Exception { + final CountDownLatch posted = new CountDownLatch(1); + this.transport.onPost = posted::countDown; + final FlagEvaluationPipeline writer = writer(64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS); + + writer.start(); + writer.enqueue(simpleEvent("shutdown-flag", "on")); + writer.close(); + + assertTrue(posted.await(5, TimeUnit.SECONDS)); + final Map json = readJson(this.transport.posts.get(0).body).parsed; + assertNotNull(eventForFlag(json, "shutdown-flag")); + } + + @Test + void continuousTrafficFlushesWithoutWaitingForIdle() throws Exception { + final FlagEvaluationPipeline writer = writer(1 << 12, 1, TimeUnit.MILLISECONDS); + + writer.start(); + boolean posted = false; + try { + final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + writer.enqueue(simpleEvent("busy-flag", "on")); + if (!this.transport.posts.isEmpty()) { + posted = true; + break; + } + } + } finally { + writer.close(); + } + + assertTrue(posted); + } + + @Test + void flushPostsToFlagevaluationEndpoint() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + + setup.handler.add(event("flag-f", "on", "alloc1", "user-1", 1000L, emptyMap())); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + + assertEquals(1, setup.transport.posts.size()); + assertEquals("flagevaluation", setup.transport.posts.get(0).route); + } + + @Test + void splitPostFailureDoesNotRetryAlreadySentPayloads() { + final int limit = 1_100; + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(limit); + setup.transport.failingPost = index -> index == 2; + + for (int i = 0; i < 4; i++) { + final Map attrs = new HashMap<>(); + attrs.put("payload", repeat('x', 180)); + setup.handler.add(event("split-failure-" + i, "on", "alloc1", "user-" + i, 1000L, attrs)); + } + + setup.handler.drainAndAggregate(); + setup.handler.flush(); + assertEquals(2, setup.transport.posts.size()); + setup.handler.flush(); + assertEquals(2, setup.transport.posts.size()); + } + + private static final String HASHED_JANE_DOE = + "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b"; + + @Test + void observeFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { + // Consent travels on the event (snapshotted by the hook at evaluation time); the writer honours + // it verbatim. + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.add(piiEvent(true)); + + final Map json = flushAndCapture(setup).parsed; + + final Map ev = eventForFlag(json, "pii-flag"); + assertNotNull(ev); + assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); + final Map ctx = (Map) ev.get("context"); + assertNotNull(ctx); + final Map evalAttrs = (Map) ctx.get("evaluation"); + assertNotNull(evalAttrs); + assertEquals("us-east-1", evalAttrs.get("region")); + } + + @Test + void observeFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() throws Exception { + assertHashedTargetingKeyAndOmittedContext(piiEvent(false)); + } + + @Test + void flagEvalEventDefaultConsentHashesTargetingKeyAndOmitsContext() throws Exception { + // An event built without an explicit consent value defaults to the privacy-preserving false, so + // it must behave exactly like the explicit "false" case. This is the state the hook produces + // when no UFC has been received. + assertHashedTargetingKeyAndOmittedContext(piiEventDefaultConsent()); + } + + @Test + void consentOffPreservesErrorCodeSignalAndNeverLeaksPiiInErrorMessage() throws Exception { + // Upstream contract: the hook substitutes the ErrorCode name for the raw exception message + // under consent-off (see + // FlagEvalLoggingHookTest#errorMessageReplacedByErrorCodeUnderConsentOff). + // This wire-level guard pins that a properly-formed consent-off event (a) still surfaces the + // stable ErrorCode signal for operators and (b) never lets a PII-shaped string escape onto the + // wire. Mirrors the existing PII guards on the targeting_key axis. + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.add( + new FlagEvalEvent( + "err-flag", + null, + "alloc1", + "jane.doe@datadoghq.com", + "TYPE_MISMATCH", + 1000L, + false, + emptyMap())); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + final Map ev = eventForFlag(captured.parsed, "err-flag"); + assertNotNull(ev); + final Map error = (Map) ev.get("error"); + assertNotNull(error, "error object must be present so operators keep the ErrorCode signal"); + assertEquals("TYPE_MISMATCH", error.get("message")); + assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); + assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); + assertFalse( + captured.raw.contains("For input string"), + "no exception-message-shaped text may reach the wire under consent-off"); + } + + @Test + void deliveryFailureClearsAggregatorSoLaterFlushesRecover() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.transport.failingPost = index -> index == 1; + + setup.handler.add(simpleEvent("failed-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + assertEquals(1, setup.transport.posts.size()); + + // The bucket must not survive the failed flush. A follow-up healthy event flushes cleanly. + setup.handler.add(simpleEvent("healthy-flag", "on")); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + assertEquals(2, setup.transport.posts.size()); + final Map json = readJson(setup.transport.posts.get(1).body).parsed; + assertNull(eventForFlag(json, "failed-flag")); + assertNotNull(eventForFlag(json, "healthy-flag")); + } + + @Test + void nonFiniteContextNumbersAreSerializedAsStrings() { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + final Map attrs = new HashMap<>(); + attrs.put("bad-number", Double.NaN); + setup.handler.add(event("nan-flag", "on", "alloc1", "user-1", 1000L, true, attrs)); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + assertTrue(captured.raw.contains("\"bad-number\":\"NaN\"")); + } + + @Test + void countContextTruncatedAccumulatesPerReason() { + final FlagEvaluationPipeline writer = writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + writer.countContextTruncated("field_count"); + writer.countContextTruncated("field_count"); + writer.countContextTruncated("field_length"); + writer.flushForTest(); + + assertEquals( + 2, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, "reason:field_count")); + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, + "reason:field_length")); + } + + @Test + void hasCapacityForEnqueueReflectsQueueSaturationAndCountsPreQueueOverflow() { + final int capacity = 2; + final FlagEvaluationPipeline writer = writer(capacity, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + + assertTrue(writer.hasCapacityForEnqueue()); + + // Saturate the hand-off queue without starting the worker so no drain can free slots. + for (int i = 0; i < capacity; i++) { + writer.enqueue(simpleEvent("cap-flag-" + i, "on")); + } + assertFalse(writer.hasCapacityForEnqueue()); + + // Simulate a pre-queue overflow account by the hook and surface it on flush. + writer.countPreQueueOverflow(); + writer.flushForTest(); + + assertEquals( + 1, + this.metrics.sum( + FlagEvaluationPipeline.FLAG_EVALUATION_DROPPED_METRIC, + "reason:" + FlagEvaluationPipeline.DROP_REASON_QUEUE_OVERFLOW)); + + writer.close(); + } + + private void assertHashedTargetingKeyAndOmittedContext(final FlagEvalEvent piiEvent) + throws Exception { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.add(piiEvent); + + final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); + + final Map ev = eventForFlag(captured.parsed, "pii-flag"); + assertNotNull(ev); + assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); + assertFalse(ev.containsKey("context")); + // The raw wire bytes must carry the hashed key and never leak the raw PII value or a per-event + // evaluation context (the batch envelope owns the top-level "context" key, so guard on the + // nested "evaluation" field instead). + assertTrue(captured.raw.contains(HASHED_JANE_DOE)); + assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); + assertFalse(captured.raw.contains("\"evaluation\":")); + } + + private static FlagEvalEvent piiEvent(final boolean observeFullEvaluationData) { + return event( + "pii-flag", + "on", + "alloc1", + "jane.doe@datadoghq.com", + 1000L, + observeFullEvaluationData, + piiAttrs()); + } + + private static FlagEvalEvent piiEventDefaultConsent() { + return event("pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", 1000L, piiAttrs()); + } + + private static Map piiAttrs() { + final Map attrs = new HashMap<>(); + attrs.put("region", "us-east-1"); + return attrs; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java new file mode 100644 index 00000000000..7792035d2d2 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java @@ -0,0 +1,226 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.IntPredicate; + +final class FlagEvaluationTestSupport { + + static final long REALISTIC_EVAL_MS = 1_760_000_000_000L; + + private FlagEvaluationTestSupport() {} + + static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final Map attrs) { + return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); + } + + static FlagEvalEvent event( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs) { + return new FlagEvalEvent( + flagKey, + variant, + allocationKey, + targetingKey, + null, + evalTimeMs, + observeFullEvaluationData, + attrs); + } + + static FlagEvalEvent errorEvent( + final String flagKey, final String errorMessage, final long evalTimeMs) { + return new FlagEvalEvent( + flagKey, null, null, null, errorMessage, evalTimeMs, Collections.emptyMap()); + } + + static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { + return event(flagKey, variant, "alloc1", "user-1", 1000L, Collections.emptyMap()); + } + + static String repeat(final char c, final int count) { + final char[] chars = new char[count]; + Arrays.fill(chars, c); + return new String(chars); + } + + static Map context() { + final Map context = new HashMap<>(); + context.put("service", "test-service"); + return context; + } + + static TestWriterSetup buildTestWriter() { + return buildTestWriter(FlagEvaluationPipeline.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); + } + + static TestWriterSetup buildTestWriter(final int payloadSizeLimitBytes) { + final CapturingTransport transport = new CapturingTransport(); + final RecordingMetrics metrics = new RecordingMetrics(); + final FlagEvaluationPipeline.SerializingHandlerForTest handler = + new FlagEvaluationPipeline.SerializingHandlerForTest( + transport, context(), payloadSizeLimitBytes, new RuntimeServices(metrics)); + return new TestWriterSetup(handler, transport, metrics); + } + + static CapturedJson flushAndCapture(final TestWriterSetup setup) { + final List captured = flushAndCaptureAll(setup); + assertEquals(1, captured.size(), "Expected exactly one posted payload"); + return captured.get(0); + } + + static List flushAndCaptureAll(final TestWriterSetup setup) { + setup.transport.posts.clear(); + setup.handler.drainAndAggregate(); + setup.handler.flush(); + final List json = new ArrayList<>(); + for (final Post post : setup.transport.posts) { + assertEquals("flagevaluation", post.route); + json.add(readJson(post.body)); + } + return json; + } + + static CapturedJson readJson(final byte[] body) { + assertNotNull(body, "payload must have been posted"); + return new CapturedJson( + new String(body, StandardCharsets.UTF_8), JsonReading.readObjectWithDoubles(body)); + } + + static Map flushAndCaptureJson(final TestWriterSetup setup) { + return flushAndCapture(setup).parsed; + } + + static void assertObjectWithKey(final Object o, final String expectedKey, final String msg) { + assertTrue(o instanceof Map, msg + " (must be a JSON object, not a bare string)"); + assertEquals(expectedKey, ((Map) o).get("key"), msg); + } + + @SuppressWarnings("unchecked") + static Map firstEvent(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events, "flagEvaluations array must be present"); + assertFalse(events.isEmpty(), "flagEvaluations must not be empty"); + return (Map) events.get(0); + } + + @SuppressWarnings("unchecked") + static int eventCount(final Map batch) { + final List events = (List) batch.get("flagEvaluations"); + assertNotNull(events, "flagEvaluations array must be present"); + return events.size(); + } + + @SuppressWarnings("unchecked") + static Map eventForFlag(final Map batch, final String flagKey) { + final List events = (List) batch.get("flagEvaluations"); + for (final Object o : events) { + final Map ev = (Map) o; + final Map flag = (Map) ev.get("flag"); + if (flag != null && flagKey.equals(flag.get("key"))) { + return ev; + } + } + return null; + } + + /** Records the posted payloads, optionally failing some posts. */ + static final class CapturingTransport implements EventTransport { + final List posts = new CopyOnWriteArrayList<>(); + volatile IntPredicate failingPost = index -> false; + volatile Runnable onPost = () -> {}; + + @Override + public void post(final String route, final byte[] json) throws IOException { + this.posts.add(new Post(route, json)); + this.onPost.run(); + if (this.failingPost.test(this.posts.size())) { + throw new IOException("boom"); + } + } + } + + static final class Post { + final String route; + final byte[] body; + + Post(final String route, final byte[] body) { + this.route = route; + this.body = body; + } + } + + /** Records health counters, keyed by their {@code reason:} tag like the agent collector. */ + static final class RecordingMetrics implements HealthMetrics { + private final List counts = new CopyOnWriteArrayList<>(); + + @Override + public void count(final String metric, final long value, final String reason) { + this.counts.add( + new String[] {metric, Long.toString(value), reason == null ? null : "reason:" + reason}); + } + + long sum(final String metricName, final String tag) { + long sum = 0; + for (final String[] count : this.counts) { + if (metricName.equals(count[0]) + && (tag == null ? count[2] == null : tag.equals(count[2]))) { + sum += Long.parseLong(count[1]); + } + } + return sum; + } + } + + static class TestWriterSetup { + final FlagEvaluationPipeline.SerializingHandlerForTest handler; + final CapturingTransport transport; + final RecordingMetrics metrics; + + TestWriterSetup( + final FlagEvaluationPipeline.SerializingHandlerForTest handler, + final CapturingTransport transport, + final RecordingMetrics metrics) { + this.handler = handler; + this.transport = transport; + this.metrics = metrics; + } + } + + static class CapturedJson { + final String raw; + final Map parsed; + + CapturedJson(final String raw, final Map parsed) { + this.raw = raw; + this.parsed = parsed; + } + } +} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java similarity index 99% rename from products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java index e3d78ac4b86..435bf9ac17f 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/ufc/v1/ParsedSemverTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java @@ -1,4 +1,4 @@ -package datadog.trace.api.featureflag.ufc.v1; +package com.datadog.openfeature.internal.ufc; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java similarity index 88% rename from products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java rename to dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java index fae2631ce2f..8a7af915e14 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/JsonApiUfcResponseParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java @@ -1,4 +1,4 @@ -package com.datadog.featureflag; +package com.datadog.openfeature.internal.ufc; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -8,21 +8,12 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.squareup.moshi.JsonQualifier; -import com.squareup.moshi.Moshi; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; import java.io.IOException; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.Collections; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -class JsonApiUfcResponseParserTest { +class UniversalFlagConfigParserJsonApiTest { @Test void parsesJsonApiMembersInAnyOrder() throws Exception { @@ -257,47 +248,44 @@ void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { assertTrue(configuration.flags.containsKey("unsigned-total-shards")); assertEquals( 2_147_483_648L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .totalShards)); + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .totalShards); assertEquals( 2_147_483_648L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .ranges - .get(0) - .start)); + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .ranges + .get(0) + .start); assertEquals( 2_147_483_649L, - Integer.toUnsignedLong( - configuration - .flags - .get("unsigned-total-shards") - .allocations - .get(0) - .splits - .get(0) - .shards - .get(0) - .ranges - .get(0) - .end)); + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .ranges + .get(0) + .end); assertFalse(configuration.flags.containsKey("too-many-shards")); assertFalse(configuration.flags.containsKey("missing-ranges")); assertFalse(configuration.flags.containsKey("null-shard")); @@ -307,23 +295,6 @@ void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { assertTrue(configuration.flags.containsKey("valid-condition-operands")); } - @Test - void shardAdapterFactoryRejectsQualifiedShard() { - assertNull( - UniversalFlagConfigParser.ShardAdapter.FACTORY.create( - Shard.class, - Collections.singleton(QualifiedShard.class.getAnnotation(ShardQualifier.class)), - new Moshi.Builder().build())); - } - - @JsonQualifier - @Retention(RetentionPolicy.RUNTIME) - @Target(ElementType.TYPE) - private @interface ShardQualifier {} - - @ShardQualifier - private static final class QualifiedShard {} - @Test void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}")); @@ -331,7 +302,8 @@ void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { @Test void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { - // Absent → Moshi leaves the boxed field null; the read site's Boolean.TRUE.equals(...) then + // Absent → the parser leaves the boxed field null; the read site's Boolean.TRUE.equals(...) + // then // resolves to the privacy-preserving default (consent-off). Either null-or-false is the // documented invariant; assert the field never reads as true. final ServerConfiguration configuration = parse(wrap(emptyConfig())); @@ -366,8 +338,8 @@ void observeFullEvaluationDataExplicitNullDefaultsToFalseWithoutRejectingConfig( @Test void observeFullEvaluationDataWrongTypedStringDefaultsToFalse() throws Exception { - // Moshi tolerates a stringified boolean like "true" via nullSafe/boxed handling: it either - // parses as null or throws locally and leaves the field null. Either way, downstream reads + // The parser tolerates a stringified boolean like "true": it leaves the field null. Either way, + // downstream reads // via Boolean.TRUE.equals(...) treat it as consent-off. The rest of the config must parse. final ServerConfiguration configuration = parse(wrap(configWithRawObserveFullEvaluationData("\"true\""))); @@ -390,7 +362,7 @@ void observeFullEvaluationDataWrongTypedNumberDefaultsToFalse() throws Exception } private static ServerConfiguration parse(final String json) throws Exception { - return JsonApiUfcResponseParser.INSTANCE.parse(json.getBytes(UTF_8)); + return UniversalFlagConfigParser.parseJsonApi(json.getBytes(UTF_8)); } private static String wrap(final String attributes) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java new file mode 100644 index 00000000000..b4a98c417fb --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -0,0 +1,273 @@ +package com.datadog.openfeature.internal.ufc; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.tabletest.junit.TableTest; + +/** Parses raw UFC documents, as delivered by Remote Configuration. */ +class UniversalFlagConfigParserTest { + + @Test + void parsesEmptyConfiguration() throws Exception { + final ServerConfiguration config = deserialize(emptyConfig()); + + assertNotNull(config); + assertTrue(config.flags.isEmpty()); + assertTrue(config.invalidFlags.isEmpty()); + } + + @Test + void parsesNullDocumentAsNoConfiguration() throws Exception { + assertNull(deserialize("null")); + } + + @Test + void rejectsNonObjectDocument() { + assertThrows(IOException.class, () -> deserialize("[]")); + } + + @Test + void rejectsDuplicateKeys() { + assertThrows(IOException.class, () -> deserialize("{\"format\":\"a\",\"format\":\"b\"}")); + } + + @Test + void rejectsWrongTypedFlagMap() { + assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); + } + + @Test + void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{" + + "\"bad\":{\"key\":\"bad\",\"enabled\":\"yes\"}," + + "\"good\":{\"key\":\"good\",\"enabled\":true}" + + "}}"); + + assertNotNull(config); + assertFalse(config.flags.containsKey("bad")); + assertEquals("invalid_flag", config.invalidFlags.get("bad")); + assertTrue(config.flags.get("good").enabled); + } + + @Test + void readsVariantNumbersAsDoubles() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true,\"variationType\":\"INTEGER\"," + + "\"variations\":{\"v\":{\"key\":\"v\",\"value\":3}}}}}"); + + assertEquals(3.0, config.flags.get("f").variations.get("v").value); + } + + @Test + void skipsMalformedFlagAllocationsAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("malformed-allocations.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("malformed-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @Test + void parsesSplitSerialId() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("340132")); + + assertNotNull(config); + assertEquals(Integer.valueOf(340132), serialIdOf(config)); + } + + @Test + void parsesSplitSerialIdZero() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("0")); + + assertNotNull(config); + assertEquals(Integer.valueOf(0), serialIdOf(config)); + } + + @Test + void parsesAbsentSplitSerialIdAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId(null)); + + assertNotNull(config); + assertNull(serialIdOf(config)); + } + + @Test + void parsesNullSplitSerialIdAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("null")); + + assertNotNull(config); + assertNull(serialIdOf(config)); + } + + @Test + void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { + final ServerConfiguration config = deserialize(configWithSiblingSerialIds("true")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("malformed-flag")); + assertEquals("invalid_flag", config.invalidFlags.get("malformed-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + /** + * Records how leniently the per-flag value reader coerces a serial id, so a future change to the + * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no + * validation of its own; what matters is that a bad one never rejects the sibling flag. + */ + @ParameterizedTest + @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) + void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) + throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); + + assertNotNull(config); + assertEquals(Integer.valueOf(expected), serialIdOf(config)); + } + + private static Integer serialIdOf(final ServerConfiguration config) { + return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; + } + + private static String configWithSerialId(final String serialIdJson) throws IOException { + return withSerialId(resource("serial-id.json"), serialIdJson); + } + + /** A malformed serial id must bind to its own flag and leave the sibling flag intact. */ + private static String configWithSiblingSerialIds(final String malformedSerialIdJson) + throws IOException { + return withSerialId(resource("sibling-serial-ids.json"), malformedSerialIdJson); + } + + /** Inserts the raw wire value so the parser sees its original JSON type; null omits the key. */ + private static String withSerialId(final String json, final String serialIdJson) { + return serialIdJson == null + ? json.replace("\"serialId\": \"${serialId}\",", "") + : json.replace("\"${serialId}\"", serialIdJson); + } + + @Test + void ignoresUnknownTopLevelFields() throws Exception { + final ServerConfiguration config = deserialize(resource("unknown-top-level-fields.json")); + + assertNotNull(config); + assertEquals("2024-04-17T19:40:53.716Z", config.createdAt); + assertEquals("SERVER", config.format); + assertNotNull(config.environment); + assertEquals("Test", config.environment.name); + assertTrue(config.flags.isEmpty()); + } + + @Test + void parsesAllocationWindowDatesWithMicrosecondPrecision() throws Exception { + final ServerConfiguration config = deserialize(resource("allocation-window-dates.json")); + + final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); + assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAt); + assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAt); + } + + @Test + void rejectsTrailingJson() { + assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); + } + + @Test + void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("unknown-operator.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("operator-grease-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @Test + void allowsNullFlagMap() throws Exception { + final ServerConfiguration config = deserialize(resource("null-flags.json")); + + assertNotNull(config); + assertNull(config.flags); + } + + @Test + void skipsNullFlagAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("null-flag.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("null-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @TableTest({ + "scenario | value | expectedInstant ", + "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", + "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", + "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", + "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", + "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", + "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", + "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", + "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", + "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", + "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", + "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", + "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", + "date only | '2023-01-01' | ", + "invalid | 'invalid-date' | ", + "empty string | '' | ", + "not a date | 'not-a-date' | ", + "slash date | '2023/01/01T00:00:00Z' | ", + "null | | " + }) + void testInstantParsing(final String value, final String expectedInstant) throws Exception { + final String startAt = value == null ? "null" : "\"" + value + "\""; + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"startAt\":" + + startAt + + "}]}}}"); + + final Instant parsed = config.flags.get("f").allocations.get(0).startAt; + if (expectedInstant == null) { + assertNull(parsed); + } else { + assertNotNull(parsed); + assertEquals(expectedInstant, parsed.toString()); + } + } + + private static ServerConfiguration deserialize(final String json) throws Exception { + return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); + } + + private static String emptyConfig() throws IOException { + return resource("empty-config.json"); + } + + private static String resource(final String name) throws IOException { + try (InputStream stream = + UniversalFlagConfigParserTest.class.getResourceAsStream("/remote-config/" + name)) { + assertNotNull(stream, "Missing remote-config fixture: " + name); + return new String(stream.readAllBytes(), UTF_8); + } + } +} diff --git a/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java new file mode 100644 index 00000000000..571748af08e --- /dev/null +++ b/dd-openfeature/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -0,0 +1,32 @@ +package datadog.trace.api.openfeature; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +/** Checks the deprecated provider alias stays source and binary compatible. */ +@SuppressWarnings("deprecation") +class ProviderTest { + @Test + void aliasIsTheDatadogProvider() { + final Provider provider = new Provider(); + + assertInstanceOf(com.datadog.openfeature.Provider.class, provider); + assertEquals("datadog-openfeature-provider", provider.getMetadata().getName()); + } + + @Test + void aliasOptionsKeepTheirType() { + final Provider.Options options = new Provider.Options(); + + final Provider.Options configured = options.initTimeout(5, SECONDS); + + assertSame(options, configured); + assertEquals(5, configured.getTimeout()); + assertEquals(SECONDS, configured.getUnit()); + assertInstanceOf(com.datadog.openfeature.Provider.class, new Provider(configured)); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json b/dd-openfeature/src/test/resources/allocation-window-dates.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/allocation-window-dates.json rename to dd-openfeature/src/test/resources/allocation-window-dates.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json b/dd-openfeature/src/test/resources/empty-config.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/empty-config.json rename to dd-openfeature/src/test/resources/empty-config.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json b/dd-openfeature/src/test/resources/malformed-allocations.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/malformed-allocations.json rename to dd-openfeature/src/test/resources/malformed-allocations.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json b/dd-openfeature/src/test/resources/null-flag.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flag.json rename to dd-openfeature/src/test/resources/null-flag.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json b/dd-openfeature/src/test/resources/null-flags.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/null-flags.json rename to dd-openfeature/src/test/resources/null-flags.json diff --git a/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json b/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json new file mode 100644 index 00000000000..26d0a90da2a --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json @@ -0,0 +1,35 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "dated-flag": { + "key": "dated-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "dated-allocation", + "rules": [], + "startAt": "2023-01-01T01:00:00.123456+01:00", + "endAt": "2023-01-02T00:00:00.987654Z", + "splits": [ + { + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/remote-config/empty-config.json b/dd-openfeature/src/test/resources/remote-config/empty-config.json new file mode 100644 index 00000000000..51b021e5de7 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/empty-config.json @@ -0,0 +1,8 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": {} +} diff --git a/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json b/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json new file mode 100644 index 00000000000..93314b69e7d --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json @@ -0,0 +1,45 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "malformed-flag": { + "key": "malformed-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "on": { + "key": "on", + "value": "on" + } + }, + "allocations": "this-is-not-a-list" + }, + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json b/dd-openfeature/src/test/resources/remote-config/null-flag.json similarity index 89% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json rename to dd-openfeature/src/test/resources/remote-config/null-flag.json index d8ff27d02a0..a4f3a6c2502 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/split-features.json +++ b/dd-openfeature/src/test/resources/remote-config/null-flag.json @@ -5,6 +5,7 @@ "name": "Test" }, "flags": { + "null-flag": null, "valid-flag": { "key": "valid-flag", "enabled": true, @@ -21,8 +22,6 @@ "rules": [], "splits": [ { - "serialId": 7, - "features": "${features}", "variationKey": "expected", "shards": [] } diff --git a/dd-openfeature/src/test/resources/remote-config/null-flags.json b/dd-openfeature/src/test/resources/remote-config/null-flags.json new file mode 100644 index 00000000000..a083526e553 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/null-flags.json @@ -0,0 +1,8 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": null +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json b/dd-openfeature/src/test/resources/remote-config/serial-id.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/serial-id.json rename to dd-openfeature/src/test/resources/remote-config/serial-id.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json b/dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/sibling-serial-ids.json rename to dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json b/dd-openfeature/src/test/resources/remote-config/unknown-operator.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-operator.json rename to dd-openfeature/src/test/resources/remote-config/unknown-operator.json diff --git a/products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json b/dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json similarity index 100% rename from products/feature-flagging/feature-flagging-lib/src/test/resources/remote-config/unknown-top-level-fields.json rename to dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json diff --git a/dd-openfeature/src/test/resources/serial-id.json b/dd-openfeature/src/test/resources/serial-id.json new file mode 100644 index 00000000000..6b7dc926854 --- /dev/null +++ b/dd-openfeature/src/test/resources/serial-id.json @@ -0,0 +1,34 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": "${serialId}", + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/sibling-serial-ids.json b/dd-openfeature/src/test/resources/sibling-serial-ids.json new file mode 100644 index 00000000000..aa791903f66 --- /dev/null +++ b/dd-openfeature/src/test/resources/sibling-serial-ids.json @@ -0,0 +1,59 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "malformed-flag": { + "key": "malformed-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "on": { + "key": "on", + "value": "on" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": "${serialId}", + "variationKey": "on", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": 7, + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/unknown-operator.json b/dd-openfeature/src/test/resources/unknown-operator.json new file mode 100644 index 00000000000..a380463b1d5 --- /dev/null +++ b/dd-openfeature/src/test/resources/unknown-operator.json @@ -0,0 +1,67 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "operator-grease-flag": { + "key": "operator-grease-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "trap": { + "key": "trap", + "value": "trap" + } + }, + "allocations": [ + { + "key": "grease-allocation", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "not-a-real-operator", + "value": "anything" + } + ] + } + ], + "splits": [ + { + "variationKey": "trap", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/unknown-top-level-fields.json b/dd-openfeature/src/test/resources/unknown-top-level-fields.json new file mode 100644 index 00000000000..fe23d4a46b1 --- /dev/null +++ b/dd-openfeature/src/test/resources/unknown-top-level-fields.json @@ -0,0 +1,13 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "segments": { + "new-schema-key": { + "ignored": true + } + }, + "flags": {} +} diff --git a/dd-smoke-tests/openfeature/application/build.gradle b/dd-smoke-tests/openfeature/application/build.gradle index 9f7f9558e9b..1b7860d0c57 100644 --- a/dd-smoke-tests/openfeature/application/build.gradle +++ b/dd-smoke-tests/openfeature/application/build.gradle @@ -31,8 +31,8 @@ if (hasProperty('featureFlaggingApiJar')) { } dependencies { - // OpenFeature SDK is an API dependency of feature-flagging-api but is not + // OpenFeature SDK is an API dependency of dd-openfeature but is not // transitively resolved when the jar is passed as a files() dependency. - implementation 'dev.openfeature:sdk:1.20.1' + implementation 'dev.openfeature:sdk:1.22.0' implementation 'org.springframework.boot:spring-boot-starter-web' } diff --git a/dd-smoke-tests/openfeature/application/src/main/java/datadog/smoketest/springboot/openfeature/OpenFeatureConfiguration.java b/dd-smoke-tests/openfeature/application/src/main/java/datadog/smoketest/springboot/openfeature/OpenFeatureConfiguration.java index f8a8ac51e45..8ed5c9083a4 100644 --- a/dd-smoke-tests/openfeature/application/src/main/java/datadog/smoketest/springboot/openfeature/OpenFeatureConfiguration.java +++ b/dd-smoke-tests/openfeature/application/src/main/java/datadog/smoketest/springboot/openfeature/OpenFeatureConfiguration.java @@ -1,6 +1,6 @@ package datadog.smoketest.springboot.openfeature; -import datadog.trace.api.openfeature.Provider; +import com.datadog.openfeature.Provider; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.OpenFeatureAPI; import org.springframework.context.annotation.Bean; diff --git a/dd-smoke-tests/openfeature/build.gradle b/dd-smoke-tests/openfeature/build.gradle index 726b6068fe9..ce7d3555799 100644 --- a/dd-smoke-tests/openfeature/build.gradle +++ b/dd-smoke-tests/openfeature/build.gradle @@ -15,12 +15,11 @@ smokeTestApp { artifactPath = 'libs/openfeature-smoketest.jar' sysProperty = 'datadog.smoketest.springboot.shadowJar.path' } - projectJar('featureFlaggingApiJar', project(':products:feature-flagging:feature-flagging-api')) + projectJar('featureFlaggingApiJar', project(':dd-openfeature')) } dependencies { testImplementation project(':dd-smoke-tests') - testImplementation project(':products:feature-flagging:feature-flagging-lib') } spotless { diff --git a/dd-smoke-tests/openfeature/gradle.lockfile b/dd-smoke-tests/openfeature/gradle.lockfile index f41834d95d2..147441f83b0 100644 --- a/dd-smoke-tests/openfeature/gradle.lockfile +++ b/dd-smoke-tests/openfeature/gradle.lockfile @@ -65,7 +65,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +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 @@ -80,8 +80,8 @@ org.dom4j:dom4j:2.2.0=spotbugs org.gmetrics:GMetrics:2.1.0=codenarc org.hamcrest:hamcrest-core:1.3=testCompileClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core-jdk11:4.0.6=testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath @@ -116,7 +116,6 @@ org.slf4j:slf4j-api:1.7.36=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j org.slf4j:slf4j-api:2.0.18=spotbugs org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j -org.snakeyaml:snakeyaml-engine:2.9=testRuntimeClasspath 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.2=testCompileClasspath,testRuntimeClasspath diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy new file mode 100644 index 00000000000..147c6fa2fde --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/AbstractOpenFeatureSmokeTest.groovy @@ -0,0 +1,213 @@ +package datadog.smoketest.springboot + +import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer + +import datadog.smoketest.AbstractServerSmokeTest +import datadog.trace.agent.test.server.http.TestHttpServer +import datadog.trace.agent.test.server.http.TestHttpServer.HandlerApi.RequestApi +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.nio.file.Files +import java.nio.file.Paths +import okhttp3.MediaType +import okhttp3.Request +import okhttp3.RequestBody +import spock.lang.AutoCleanup +import spock.lang.IgnoreIf +import spock.lang.Shared +import spock.lang.Stepwise +import spock.lang.Unroll +import spock.util.concurrent.PollingConditions + +/** Due to the exposure cache it's important to run the tests in the specified order */ +@Stepwise +abstract class AbstractOpenFeatureSmokeTest extends AbstractServerSmokeTest { + + @Shared + protected final ufcConfig = new JsonSlurper().parse(fetchResource("ffe-system-test-data/ufc-config.json")) as Map + + @Shared + protected final ufcPayload = JsonOutput.toJson(ufcConfig) + + @Shared + protected final loggedAllocations = buildLoggedAllocations(ufcConfig) + + /** Serves the flag configuration as the Datadog managed CDN does. */ + @Shared + @AutoCleanup + protected TestHttpServer cdn = httpServer { + handlers { + prefix("/api/v2/feature-flagging/config/rules-based/server") { + response.status(200).send(JsonOutput.toJson([ + data: [type: 'universal-flag-configuration', attributes: ufcConfig] + ])) + } + } + } + + /** @return whether exposures are delivered through the Datadog Agent event platform proxy. */ + abstract boolean deliversExposures() + + /** Publishes the flag configuration before each evaluation. */ + void publishConfiguration() {} + + @Override + Closure decodedTracesCallback() { + return {} + } + + @Override + Closure decodedEvpProxyMessageCallback() { + return { String path, RequestApi request -> + if (!path.contains('api/v2/exposures')) { + return null + } + return new JsonSlurper().parse(request.body) + } + } + + @IgnoreIf({ !instance.deliversExposures() }) + void 'test open feature exposures'() { + setup: + publishConfiguration() + final testCases = parseTestCases() + assert !testCases.isEmpty() + + when: + final results = testCases.collect { + testCase -> + final response = evaluate(testCase) + final responseBody = new JsonSlurper().parse(response.body().byteStream()) + return [testCase: testCase, response: response, body: responseBody] + } + final expectedExposures = uniqueExpectedExposures(results) + + then: + results.every { + it.response.code() == 200 + } + !expectedExposures.isEmpty() + new PollingConditions(timeout: 10).eventually { + final requests = evpProxyMessages*.getV2() as List> + final events = requests*.exposures.flatten() + assert events.size() == expectedExposures.size() + expectedExposures.each { + expected -> + assert events.find { + event -> + event.flag.key == expected.flag && + event.allocation.key == expected.allocation && + event.variant.key == expected.variant && + event.subject.id == expected.targetingKey + } != null : "Unable to find exposure ${expected}" + } + } + } + + @Unroll("test open feature evaluation - #testCase.fileName[#testCase.index] - flag=#testCase.flag") + void 'test open feature evaluation'() { + setup: + publishConfiguration() + + when: + final response = evaluate(testCase) + + then: + response.code() == 200 + final responseBody = new JsonSlurper().parse(response.body().byteStream()) + responseBody.value == testCase.result.value + responseBody.reason == testCase.result.reason + if (testCase.result.containsKey('errorCode')) { + assert responseBody.errorCode == testCase.result.errorCode + } + if (testCase.result.containsKey('variant')) { + assert responseBody.variant == testCase.result.variant + } + if (testCase.result.flagMetadata?.allocationKey) { + assert responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey + } + + where: + testCase << parseTestCases() + } + + protected okhttp3.Response evaluate(final Map testCase) { + final request = new Request.Builder() + .url("http://localhost:${httpPort}/openfeature/evaluate") + .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) + .build() + return client.newCall(request).execute() + } + + protected static URL fetchResource(final String name) { + return Thread.currentThread().getContextClassLoader().getResource(name) + } + + protected static List> parseTestCases() { + final folder = fetchResource('ffe-system-test-data/evaluation-cases') + final uri = folder.toURI() + final testsPath = Paths.get(uri) + final files = Files.list(testsPath) + .filter(path -> path.toString().endsWith('.json')) + .sorted(Comparator.comparing(path -> path.fileName.toString())) + final result = [] + final slurper = new JsonSlurper() + files.each { + path -> + final testCases = slurper.parse(path.toFile()) as List> + testCases.eachWithIndex { + testCase, index -> + testCase.fileName = path.fileName.toString() + testCase.index = index + } + result.addAll(testCases) + } + assert !result.isEmpty() + return result + } + + protected List> uniqueExpectedExposures(final List> results) { + final expected = [] + final seen = [] as Set + results.each { result -> + final testCase = result.testCase as Map + final body = result.body as Map + final flag = testCase.flag as String + final allocation = body.flagMetadata?.allocationKey as String + final variant = body.variant as String + if (!variant || !allocation || !allocationLogs(flag, allocation)) { + return + } + + final exposure = [ + flag: flag, + allocation: allocation, + variant: variant, + targetingKey: testCase.targetingKey + ] + final key = "${exposure.flag}\u0000${exposure.targetingKey}\u0000${exposure.allocation}\u0000${exposure.variant}" + if (seen.add(key)) { + expected.add(exposure) + } + } + return expected + } + + protected boolean allocationLogs(final String flag, final String allocation) { + return loggedAllocations["${flag}\u0000${allocation}"] == true + } + + protected static Map buildLoggedAllocations(final Map config) { + final logged = [:] + (config.flags as Map).each { flag, definition -> + if (definition.allocations instanceof List) { + definition.allocations.each { allocation -> + if (allocation instanceof Map) { + logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true + } + } + } + } + return logged + } +} diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy new file mode 100644 index 00000000000..bf7932f7ea8 --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureAgentlessSmokeTest.groovy @@ -0,0 +1,24 @@ +package datadog.smoketest.springboot + +/** + * The SDK with the Datadog Java agent and the default agentless configuration source: the SDK + * polls the flag configuration from the CDN and delivers exposures through the agent. + */ +class OpenFeatureAgentlessSmokeTest extends AbstractOpenFeatureSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + final command = [javaPath()] + command.addAll(defaultJavaProperties) + command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()]) + final builder = new ProcessBuilder(command).directory(new File(buildDirectory)) + builder.environment().put('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL', "http://localhost:${cdn.address.port}".toString()) + return builder + } + + @Override + boolean deliversExposures() { + return true + } +} diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy index 12115cb407d..d0808be6aea 100644 --- a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureProviderSmokeTest.groovy @@ -2,36 +2,18 @@ package datadog.smoketest.springboot import datadog.remoteconfig.Capabilities import datadog.remoteconfig.Product -import datadog.smoketest.AbstractServerSmokeTest -import datadog.trace.agent.test.server.http.TestHttpServer.HandlerApi.RequestApi import groovy.json.JsonOutput import groovy.json.JsonSlurper -import java.nio.file.Files -import java.nio.file.Paths import okhttp3.MediaType import okhttp3.Request import okhttp3.RequestBody -import spock.lang.Shared -import spock.lang.Stepwise -import spock.lang.Unroll -import spock.util.concurrent.PollingConditions -/** Due to the exposure cache it's important to run the tests in the specified order */ -@Stepwise -class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { - - @Shared - private final rcConfig = new JsonSlurper().parse(fetchResource("ffe-system-test-data/ufc-config.json")) as Map - - @Shared - private final rcPayload = JsonOutput.toJson(rcConfig) - - @Shared - private final loggedAllocations = buildLoggedAllocations(rcConfig) +/** The SDK with the Datadog Java agent, receiving the flag configuration from Remote Configuration. */ +class OpenFeatureProviderSmokeTest extends AbstractOpenFeatureSmokeTest { @Override ProcessBuilder createProcessBuilder() { - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) + setRemoteConfig("datadog/2/FFE_FLAGS/1/config", ufcPayload) final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") final command = [javaPath()] @@ -48,18 +30,13 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { } @Override - Closure decodedTracesCallback() { - return {} + boolean deliversExposures() { + return true } @Override - Closure decodedEvpProxyMessageCallback() { - return { String path, RequestApi request -> - if (!path.contains('api/v2/exposures')) { - return null - } - return new JsonSlurper().parse(request.body) - } + void publishConfiguration() { + setRemoteConfig("datadog/2/FFE_FLAGS/1/config", ufcPayload) } void 'test first remote config poll asks agent for feature flags'() { @@ -79,7 +56,7 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { void 'test open feature evaluation enriches the request span'() { setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) + publishConfiguration() final request = new Request.Builder() .url("http://localhost:${httpPort}/openfeature/evaluate") .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson([ @@ -105,152 +82,6 @@ class OpenFeatureProviderSmokeTest extends AbstractServerSmokeTest { response.close() } - void 'test open feature exposures'() { - setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) - final url = "http://localhost:${httpPort}/openfeature/evaluate" - final testCases = parseTestCases() - assert !testCases.isEmpty() - - when: - final results = testCases.collect { - testCase -> - final request = new Request.Builder() - .url(url) - .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) - .build() - final response = client.newCall(request).execute() - final responseBody = new JsonSlurper().parse(response.body().byteStream()) - return [testCase: testCase, response: response, body: responseBody] - } - final expectedExposures = uniqueExpectedExposures(results) - - then: - results.every { - it.response.code() == 200 - } - !expectedExposures.isEmpty() - new PollingConditions(timeout: 10).eventually { - final requests = evpProxyMessages*.getV2() as List> - final events = requests*.exposures.flatten() - assert events.size() == expectedExposures.size() - expectedExposures.each { - expected -> - assert events.find { - event -> - event.flag.key == expected.flag && - event.allocation.key == expected.allocation && - event.variant.key == expected.variant && - event.subject.id == expected.targetingKey - } != null : "Unable to find exposure ${expected}" - } - } - } - - @Unroll("test open feature evaluation - #testCase.fileName[#testCase.index] - flag=#testCase.flag") - void 'test open feature evaluation'() { - setup: - setRemoteConfig("datadog/2/FFE_FLAGS/1/config", rcPayload) - final url = "http://localhost:${httpPort}/openfeature/evaluate" - final request = new Request.Builder() - .url(url) - .post(RequestBody.create(MediaType.parse('application/json'), JsonOutput.toJson(testCase))) - .build() - - when: - final response = client.newCall(request).execute() - - then: - response.code() == 200 - final responseBody = new JsonSlurper().parse(response.body().byteStream()) - responseBody.value == testCase.result.value - responseBody.reason == testCase.result.reason - if (testCase.result.containsKey('errorCode')) { - assert responseBody.errorCode == testCase.result.errorCode - } - if (testCase.result.containsKey('variant')) { - assert responseBody.variant == testCase.result.variant - } - if (testCase.result.flagMetadata?.allocationKey) { - assert responseBody.flagMetadata?.allocationKey == testCase.result.flagMetadata?.allocationKey - } - - where: - testCase << parseTestCases() - } - - private static URL fetchResource(final String name) { - return Thread.currentThread().getContextClassLoader().getResource(name) - } - - private static List> parseTestCases() { - final folder = fetchResource('ffe-system-test-data/evaluation-cases') - final uri = folder.toURI() - final testsPath = Paths.get(uri) - final files = Files.list(testsPath) - .filter(path -> path.toString().endsWith('.json')) - .sorted(Comparator.comparing(path -> path.fileName.toString())) - final result = [] - final slurper = new JsonSlurper() - files.each { - path -> - final testCases = slurper.parse(path.toFile()) as List> - testCases.eachWithIndex { - testCase, index -> - testCase.fileName = path.fileName.toString() - testCase.index = index - } - result.addAll(testCases) - } - assert !result.isEmpty() - return result - } - - private List> uniqueExpectedExposures(final List> results) { - final expected = [] - final seen = [] as Set - results.each { result -> - final testCase = result.testCase as Map - final body = result.body as Map - final flag = testCase.flag as String - final allocation = body.flagMetadata?.allocationKey as String - final variant = body.variant as String - if (!variant || !allocation || !allocationLogs(flag, allocation)) { - return - } - - final exposure = [ - flag: flag, - allocation: allocation, - variant: variant, - targetingKey: testCase.targetingKey - ] - final key = "${exposure.flag}\u0000${exposure.targetingKey}\u0000${exposure.allocation}\u0000${exposure.variant}" - if (seen.add(key)) { - expected.add(exposure) - } - } - return expected - } - - private boolean allocationLogs(final String flag, final String allocation) { - return loggedAllocations["${flag}\u0000${allocation}"] == true - } - - private static Map buildLoggedAllocations(final Map config) { - final logged = [:] - (config.flags as Map).each { flag, definition -> - if (definition.allocations instanceof List) { - definition.allocations.each { allocation -> - if (allocation instanceof Map) { - logged["${flag}\u0000${allocation.key}"] = allocation.doLog == true - } - } - } - } - return logged - } - private static Set decodeProducts(final Map request) { return request.client.products.collect { Product.valueOf(it) } } diff --git a/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy new file mode 100644 index 00000000000..86bbf6e41be --- /dev/null +++ b/dd-smoke-tests/openfeature/src/test/groovy/datadog/smoketest/springboot/OpenFeatureStandaloneSmokeTest.groovy @@ -0,0 +1,35 @@ +package datadog.smoketest.springboot + +/** + * The SDK without the Datadog Java agent: the SDK polls the flag configuration from the CDN. Without + * an API key, events are not delivered. + */ +class OpenFeatureStandaloneSmokeTest extends AbstractOpenFeatureSmokeTest { + + @Override + ProcessBuilder createProcessBuilder() { + final springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") + final command = [javaPath()] + command.addAll(nativeJavaProperties) + command.addAll(['-jar', springBootShadowJar, "--server.port=${httpPort}".toString()]) + final builder = new ProcessBuilder(command).directory(new File(buildDirectory)) + builder.environment().put('DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL', "http://localhost:${cdn.address.port}".toString()) + return builder + } + + @Override + boolean deliversExposures() { + return false + } + + @Override + boolean testTelemetry() { + return false + } + + /** Disables direct intake delivery, which would otherwise reach the real Datadog intake. */ + @Override + String apiKey() { + return '' + } +} diff --git a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java index bf5bab30d05..aed85035b09 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java @@ -49,8 +49,6 @@ public final class ConfigDefaults { static final boolean DEFAULT_INJECT_DATADOG_ATTRIBUTE = true; static final String DEFAULT_SITE = "datadoghq.com"; static final String DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE = "agentless"; - static final int DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS = 30; - static final int DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS = 5; static final boolean DEFAULT_CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT = false; static final int DEFAULT_CODE_ORIGIN_MAX_USER_FRAMES = 8; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f106f80c147..5ca41e88055 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -163,6 +163,7 @@ jul-to-slf4j = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } # JSON +jackson-core = {module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jackson"} jackson-databind = {module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson"} moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index b1707b77a71..249af11be8e 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -89,8 +89,6 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_BODY_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_PARAMS_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_EXPERIMENTATAL_JEE_SPLIT_BY_DEPLOYMENT; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_GRPC_CLIENT_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_GRPC_SERVER_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_HEALTH_METRICS_ENABLED; @@ -746,9 +744,6 @@ import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.isSupportedConfigurationSource; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; @@ -1260,9 +1255,6 @@ public static String getHostName() { private final boolean featureFlaggingProviderEnabled; private final String featureFlaggingConfigurationSource; - private final String featureFlaggingConfigurationSourceAgentlessBaseUrl; - private final int featureFlaggingConfigurationSourcePollIntervalSeconds; - private final int featureFlaggingConfigurationSourceRequestTimeoutSeconds; private final boolean dbmInjectSqlBaseHash; private final String dbmPropagationMode; @@ -2978,35 +2970,6 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) } featureFlaggingProviderEnabled = resolvedFeatureFlaggingConfiguration.isEnabled(); featureFlaggingConfigurationSource = resolvedFeatureFlaggingConfiguration.getSource(); - featureFlaggingConfigurationSourceAgentlessBaseUrl = - configProvider.getStringNotEmpty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, null); - int configuredFeatureFlaggingPollIntervalSeconds = - configProvider.getInteger( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS); - if (configuredFeatureFlaggingPollIntervalSeconds <= 0) { - log.warn( - "Invalid Feature Flagging agentless poll interval: {}. The value must be positive", - configuredFeatureFlaggingPollIntervalSeconds); - configuredFeatureFlaggingPollIntervalSeconds = - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; - } - featureFlaggingConfigurationSourcePollIntervalSeconds = - configuredFeatureFlaggingPollIntervalSeconds; - int configuredFeatureFlaggingRequestTimeoutSeconds = - configProvider.getInteger( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS); - if (configuredFeatureFlaggingRequestTimeoutSeconds <= 0) { - log.warn( - "Invalid Feature Flagging agentless request timeout: {}. The value must be positive", - configuredFeatureFlaggingRequestTimeoutSeconds); - configuredFeatureFlaggingRequestTimeoutSeconds = - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; - } - featureFlaggingConfigurationSourceRequestTimeoutSeconds = - configuredFeatureFlaggingRequestTimeoutSeconds; dynamicInstrumentationEnabled = configProvider.getBoolean( @@ -4947,18 +4910,6 @@ public String getFeatureFlaggingConfigurationSource() { return featureFlaggingConfigurationSource; } - public String getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() { - return featureFlaggingConfigurationSourceAgentlessBaseUrl; - } - - public int getFeatureFlaggingConfigurationSourcePollIntervalSeconds() { - return featureFlaggingConfigurationSourcePollIntervalSeconds; - } - - public int getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds() { - return featureFlaggingConfigurationSourceRequestTimeoutSeconds; - } - public boolean isDynamicInstrumentationEnabled() { return dynamicInstrumentationEnabled; } @@ -6919,12 +6870,6 @@ public String toString() { + featureFlaggingProviderEnabled + ", featureFlaggingConfigurationSource=" + featureFlaggingConfigurationSource - + ", featureFlaggingConfigurationSourceAgentlessBaseUrl=" - + featureFlaggingConfigurationSourceAgentlessBaseUrl - + ", featureFlaggingConfigurationSourcePollIntervalSeconds=" - + featureFlaggingConfigurationSourcePollIntervalSeconds - + ", featureFlaggingConfigurationSourceRequestTimeoutSeconds=" - + featureFlaggingConfigurationSourceRequestTimeoutSeconds + ", debuggerEnabled=" + dynamicInstrumentationEnabled + ", debuggerUploadTimeout=" diff --git a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java index 76b65d43036..6af92039fe3 100644 --- a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java @@ -98,6 +98,10 @@ import static datadog.trace.api.config.TraceInstrumentationConfig.UNSAFE_CLASS_INJECTION; import static datadog.trace.api.config.TraceInstrumentationConfig.VISITOR_CLASS_PARSING; import static datadog.trace.api.config.UsmConfig.USM_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; import static datadog.trace.util.CollectionUtils.tryMakeImmutableList; import static datadog.trace.util.CollectionUtils.tryMakeImmutableSet; import static java.util.Collections.singletonList; @@ -175,6 +179,7 @@ public class InstrumenterConfig { private final boolean usmEnabled; private final boolean telemetryEnabled; private final boolean llmObsEnabled; + private final boolean featureFlaggingEnabled; private final String traceExtensionsPath; @@ -276,6 +281,12 @@ private InstrumenterConfig() { CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT, DEFAULT_CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT); traceEnabled = configProvider.getBoolean(TRACE_ENABLED, DEFAULT_TRACE_ENABLED); + featureFlaggingEnabled = + resolveConfiguration( + configProvider.getBoolean(FEATURE_FLAGS_ENABLED), + configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE), + configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED)) + .isEnabled(); traceOtelEnabled = configProvider.getBoolean(TRACE_OTEL_ENABLED, DEFAULT_TRACE_OTEL_ENABLED); metricsOtelEnabled = configProvider.getBoolean(METRICS_OTEL_ENABLED, DEFAULT_METRICS_OTEL_ENABLED); @@ -491,6 +502,10 @@ public boolean isTraceEnabled() { return traceEnabled; } + public boolean isFeatureFlaggingEnabled() { + return featureFlaggingEnabled; + } + public boolean isTraceOtelEnabled() { return traceOtelEnabled; } diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java index 526e15a1a00..2b6819db8d1 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java @@ -1,8 +1,6 @@ package datadog.trace.api; import static datadog.trace.api.ConfigDefaults.DEFAULT_AGENT_HOST; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_HTTP_CLIENT_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_HTTP_SERVER_ERROR_STATUSES; import static datadog.trace.api.ConfigDefaults.DEFAULT_PARTIAL_FLUSH_MIN_SPANS; @@ -169,8 +167,6 @@ import static datadog.trace.api.env.FixedCapturedEnvironment.useFixedEnv; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.bootstrap.config.provider.ConfigProvider.withPropertiesOverride; import static java.util.Arrays.asList; @@ -3652,20 +3648,6 @@ void traceResourceRenamingAlwaysSimplifiedEndpointWithValue(String value, boolea assertEquals(expected, config.isTraceResourceRenamingAlwaysSimplifiedEndpoint()); } - @Test - void agentlessFeatureFlagTimingUsesPositiveConfiguredValues() throws Exception { - Properties properties = new Properties(); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, "60"); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, "4"); - - Config config = newConfig(withPropertiesOverride(properties)); - - assertEquals(60, config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()); - assertEquals(4, config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()); - } - @TableTest({ "scenario | value | expected ", "not set | | agentless ", @@ -3725,23 +3707,6 @@ void featureFlagConfigurationAppliesMigrationPrecedence( assertEquals(expectedSource, config.getFeatureFlaggingConfigurationSource()); } - @Test - void agentlessFeatureFlagTimingFallsBackForNonPositiveValues() throws Exception { - Properties properties = new Properties(); - properties.setProperty(FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS, "0"); - properties.setProperty( - FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS, "-1"); - - Config config = newConfig(withPropertiesOverride(properties)); - - assertEquals( - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS, - config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()); - assertEquals( - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS, - config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()); - } - /** Reads a private {@link Config} field that has no getter. */ private static Object fieldValue(Config config, String fieldName) throws ReflectiveOperationException { diff --git a/metadata/agent-jar-checks.properties b/metadata/agent-jar-checks.properties index 82bc8070731..27b576ea1c4 100644 --- a/metadata/agent-jar-checks.properties +++ b/metadata/agent-jar-checks.properties @@ -128,6 +128,7 @@ expected.integrations = IastInstrumentation,\ ognl,\ okhttp,\ openai-java,\ + openfeature,\ opensearch,\ opentelemetry-annotations,\ opentelemetry-beta,\ diff --git a/products/feature-flagging/feature-flagging-agent/gradle.lockfile b/products/feature-flagging/feature-flagging-agent/gradle.lockfile index dc1fc729597..f295d3c4dc8 100644 --- a/products/feature-flagging/feature-flagging-agent/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-agent/gradle.lockfile @@ -47,7 +47,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +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 @@ -65,8 +65,8 @@ org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt org.jacoco:org.jacoco.core:0.8.15=jacocoAnt org.jacoco:org.jacoco.report:0.8.15=jacocoAnt -org.jctools:jctools-core-jdk11:4.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath +org.jctools:jctools-core:4.0.6=testRuntimeClasspath 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 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 7f105d00213..30366542376 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 @@ -1,213 +1,52 @@ package com.datadog.featureflag; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; - import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Starts the agent Feature Flags backend used by the {@code dd-openfeature} SDK instrumentation. + * Apart from registering the Remote Configuration product, the backend does no work until the SDK + * uses it. + */ public class FeatureFlaggingSystem { - - @FunctionalInterface - interface SystemInitializer { - void initialize(SharedCommunicationObjects sco, Config config); - } - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlaggingSystem.class); - private static volatile ConfigurationSourceService CONFIG_SERVICE; - private static volatile ExposureWriter EXPOSURE_WRITER; - private static volatile FlagEvaluationWriter FLAG_EVAL_WRITER; - private static volatile SpanEnrichmentWriter SPAN_ENRICHMENT_WRITER; - private static volatile FeatureFlaggingGateway.ActivationListener ACTIVATION_LISTENER; - private static volatile boolean STARTED; + private static volatile FeatureFlagsBackend BACKEND; private FeatureFlaggingSystem() {} - public static void start(final SharedCommunicationObjects sco) { - start(sco, FeatureFlaggingSystem::initializeSystem); - } - - static synchronized void start( - final SharedCommunicationObjects sco, final SystemInitializer systemInitializer) { - if (STARTED) { + public static synchronized void start(final SharedCommunicationObjects sco) { + if (BACKEND != null) { LOGGER.debug("Feature Flagging system already started"); return; } - LOGGER.debug("Feature Flagging system starting"); final Config config = Config.get(); - STARTED = true; - if (!config.isFeatureFlaggingProviderEnabled()) { LOGGER.debug("Feature Flagging system disabled"); return; } - - if (CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - final FeatureFlaggingGateway.ActivationListener activationListener = - () -> activateAgentless(sco, config, systemInitializer); - ACTIVATION_LISTENER = activationListener; - FeatureFlaggingGateway.addActivationListener(activationListener); - LOGGER.debug("Feature Flagging system awaiting application provider activation"); - return; - } - - initializeOrRollBack(sco, config, systemInitializer); - } - - private static synchronized void activateAgentless( - final SharedCommunicationObjects sco, - final Config config, - final SystemInitializer systemInitializer) { - final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; - if (!STARTED || activationListener == null) { - return; - } - ACTIVATION_LISTENER = null; - FeatureFlaggingGateway.removeActivationListener(activationListener); - initializeOrRollBack(sco, config, systemInitializer); - } - - // Any failure leaves the subsystem fully stopped: stop() releases whatever initializeSystem - // managed to publish before it threw, so a later start() begins from a clean state. - private static void initializeOrRollBack( - final SharedCommunicationObjects sco, - final Config config, - final SystemInitializer systemInitializer) { - try { - systemInitializer.initialize(sco, config); - } catch (final RuntimeException | Error e) { - stop(); - throw e; - } - } - - private static void initializeSystem(final SharedCommunicationObjects sco, final Config config) { - final ConfigurationSourceService configService = createConfigurationSourceService(sco, config); - if (configService == null) { - LOGGER.debug("Feature Flagging system disabled by unsupported configuration source"); - return; - } - final ExposureWriter exposureWriter = new ExposureWriterImpl(sco, config); - initialize(configService, exposureWriter); - - final boolean evalCountsEnabled = - config - .configProvider() - .getBoolean(FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, true); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(evalCountsEnabled); - if (evalCountsEnabled) { - final FlagEvaluationWriterImpl evalWriter = new FlagEvaluationWriterImpl(sco, config); - // Publish before start() so a failed start is still reachable by the rollback in stop(). - FLAG_EVAL_WRITER = evalWriter; - evalWriter.start(); - LOGGER.debug("Flag evaluation EVP writer started"); - } else { - FeatureFlaggingGateway.setFlagEvalWriter(null); - LOGGER.debug( - "Flag evaluation EVP writer disabled ({}=false)", - FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED); - } - - // 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(); - + final FeatureFlagsBackend backend = new FeatureFlagsBackend(sco, config); + backend.start(); + BACKEND = backend; + FeatureFlaggingGateway.register(backend); LOGGER.debug("Feature Flagging system started"); } - static void initialize( - final ConfigurationSourceService configService, final ExposureWriter exposureWriter) { - try { - if (configService != null) { - configService.init(); - } - exposureWriter.init(); - CONFIG_SERVICE = configService; - EXPOSURE_WRITER = exposureWriter; - } catch (final RuntimeException | Error e) { - try { - exposureWriter.close(); - } finally { - if (configService != null) { - configService.close(); - } - } - throw e; - } - } - - static ConfigurationSourceService createConfigurationSourceService( - final SharedCommunicationObjects sco, final Config config) { - final String configurationSource = config.getFeatureFlaggingConfigurationSource(); - if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(configurationSource)) { - if (!config.isRemoteConfigEnabled()) { - throw new IllegalStateException("Feature Flagging system started without RC"); - } - return new RemoteConfigServiceImpl(sco, config); - } - if (CONFIGURATION_SOURCE_AGENTLESS.equals(configurationSource)) { - return new AgentlessConfigurationSource(config); - } - return null; - } - @SuppressFBWarnings( value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", justification = "Agent-internal class; Class object does not escape to app code and lock only guards the subsystem lifecycle.") public static synchronized void stop() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - FeatureFlaggingGateway.setFlagEvalWriter(null); - final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; - final FlagEvaluationWriter flagEvalWriter = FLAG_EVAL_WRITER; - final SpanEnrichmentWriter spanEnrichmentWriter = SPAN_ENRICHMENT_WRITER; - final ExposureWriter exposureWriter = EXPOSURE_WRITER; - final ConfigurationSourceService configService = CONFIG_SERVICE; - STARTED = false; - ACTIVATION_LISTENER = null; - FLAG_EVAL_WRITER = null; - SPAN_ENRICHMENT_WRITER = null; - EXPOSURE_WRITER = null; - CONFIG_SERVICE = null; - if (activationListener != null) { - FeatureFlaggingGateway.removeActivationListener(activationListener); + final FeatureFlagsBackend backend = BACKEND; + BACKEND = null; + FeatureFlaggingGateway.register(null); + if (backend != null) { + backend.close(); } - closeQuietly(flagEvalWriter); - closeQuietly(spanEnrichmentWriter); - closeQuietly(exposureWriter); - closeQuietly(configService); LOGGER.debug("Feature Flagging system stopped"); } - - static boolean isAwaitingApplicationActivation() { - return ACTIVATION_LISTENER != null; - } - - static boolean isExposureWriterStarted() { - return EXPOSURE_WRITER != null; - } - - static boolean isConfigurationSourceStarted() { - return CONFIG_SERVICE != null; - } - - private static void closeQuietly(final AutoCloseable resource) { - if (resource != null) { - try { - resource.close(); - } catch (Exception ignored) { - } - } - } } diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java index 5b88389aefc..f3c2ff98f8b 100644 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java +++ b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java @@ -1,404 +1,49 @@ package com.datadog.featureflag; -import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIGURATION_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; -import datadog.communication.ddagent.DDAgentFeaturesDiscovery; import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.remoteconfig.Capabilities; -import datadog.remoteconfig.ConfigurationDeserializer; -import datadog.remoteconfig.ConfigurationPoller; -import datadog.remoteconfig.Product; -import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; import datadog.trace.test.junit.utils.config.WithConfig; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; class FeatureFlaggingSystemTest { @AfterEach - void resetFlagEvaluationGateway() { + void stop() { FeatureFlaggingSystem.stop(); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); } @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://127.0.0.1:1") - void agentlessStartWaitsForApplicationProviderActivationWithoutPreparingDelivery() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - clearInvocations(sharedCommunicationObjects); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } - - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void agentlessActivationInitializesSystemOnce() { - final SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - final FeatureFlaggingSystem.SystemInitializer systemInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - - FeatureFlaggingSystem.start(sharedCommunicationObjects, systemInitializer); - - verifyNoInteractions(systemInitializer); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - - FeatureFlaggingGateway.activate(); - FeatureFlaggingGateway.activate(); - - verify(systemInitializer).initialize(eq(sharedCommunicationObjects), any(Config.class)); - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - void agentlessInitializationFailureCleansUpAndAllowsRetry() { - final SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - final FeatureFlaggingSystem.SystemInitializer failedInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - final IllegalStateException initializationFailure = - new IllegalStateException("system initialization failed"); - doThrow(initializationFailure) - .when(failedInitializer) - .initialize(any(SharedCommunicationObjects.class), any(Config.class)); - - FeatureFlaggingSystem.start(sharedCommunicationObjects, failedInitializer); - - final IllegalStateException thrown = - assertThrows(IllegalStateException.class, FeatureFlaggingGateway::activate); - - assertSame(initializationFailure, thrown); - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertFalse(FeatureFlaggingSystem.isExposureWriterStarted()); - assertFalse(FeatureFlaggingSystem.isConfigurationSourceStarted()); - - final FeatureFlaggingSystem.SystemInitializer retryInitializer = - mock(FeatureFlaggingSystem.SystemInitializer.class); - FeatureFlaggingSystem.start(sharedCommunicationObjects, retryInitializer); - FeatureFlaggingGateway.activate(); - - verify(retryInitializer).initialize(eq(sharedCommunicationObjects), any(Config.class)); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://127.0.0.1:1") - void agentlessStopRemovesPendingApplicationProviderActivation() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - clearInvocations(sharedCommunicationObjects); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - - FeatureFlaggingSystem.stop(); - clearInvocations(sharedCommunicationObjects); - FeatureFlaggingGateway.activate(); - - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "true") - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") - void testFeatureFlagSystemInitialization() { - ConfigurationPoller poller = mock(ConfigurationPoller.class); - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - when(discovery.supportsEvpProxy()).thenReturn(true); - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); - when(sharedCommunicationObjects.configurationPoller(any(Config.class))).thenReturn(poller); - when(sharedCommunicationObjects.featuresDiscovery(any(Config.class))).thenReturn(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get("http://localhost"); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - - FeatureFlaggingSystem.start(sharedCommunicationObjects); - FeatureFlaggingSystem.start(sharedCommunicationObjects); - - verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), any(ConfigurationDeserializer.class), any()); - verify(poller).start(); - assertTrue(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNotNull(FeatureFlaggingGateway.getFlagEvalWriter()); + @WithConfig(key = FEATURE_FLAGS_ENABLED, value = "true") + void startRegistersTheBackendAndStopUnregistersIt() { + FeatureFlaggingSystem.start(mock(SharedCommunicationObjects.class)); + assertInstanceOf(FeatureFlagsBackend.class, FeatureFlaggingGateway.backend()); FeatureFlaggingSystem.stop(); - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - // stop() is idempotent: a second call must be a safe no-op. - FeatureFlaggingSystem.stop(); - - verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).removeListeners(Product.FFE_FLAGS); - verify(poller).stop(); - } - - @Test - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "false") - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - void testFlagEvaluationWriterCanBeDisabled() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - FeatureFlaggingGateway.setFlagEvalWriter(mock(FlagEvaluationWriter.class)); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - // Agentless defers initialization until the application provider activates. - FeatureFlaggingGateway.activate(); - - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - void testFeatureFlagSystemShutdownClearsGatewayState() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - FeatureFlaggingGateway.setFlagEvalWriter(mock(FlagEvaluationWriter.class)); - - FeatureFlaggingSystem.stop(); - - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void failedStartRollsBackPartiallyInitializedState() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - - // A failed start must leave nothing behind: no listener awaiting activation, no gateway - // writer, and STARTED cleared so a later start() is not swallowed as "already started". - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - assertFalse(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void testThatRemoteConfigIsRequired() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertThrows( - IllegalStateException.class, - () -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - void agentlessConfigurationSourceUsesHttpServiceWithoutRemoteConfig() { - assertInstanceOf( - AgentlessConfigurationSource.class, - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://localhost:1/config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "false") - @WithConfig(key = FeatureFlaggingConfig.FLAGGING_EVALUATION_COUNTS_ENABLED, value = "true") - void agentlessConfigurationSourceStartsTelemetryWritersWithoutRemoteConfig() { - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects()); - // Agentless defers initialization until the application provider activates. - FeatureFlaggingGateway.activate(); - - assertTrue(FeatureFlaggingSystem.isExposureWriterStarted()); - assertTrue(FeatureFlaggingSystem.isConfigurationSourceStarted()); - assertTrue(FeatureFlaggingGateway.isFlagEvaluationEnqueueEnabled()); - assertNotNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") - @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") - void explicitRemoteConfigUsesRemoteConfigService() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - when(sharedCommunicationObjects.configurationPoller(any(Config.class))) - .thenReturn(mock(ConfigurationPoller.class)); - - assertInstanceOf( - RemoteConfigServiceImpl.class, - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects, Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") - void offlineConfigurationSourceDoesNotStartNetworkSource() { - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") - void invalidConfigurationSourceDoesNotStartNetworkSource() { - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), Config.get())); - } - - @Test - void unsupportedNormalizedConfigurationSourceDoesNotStartNetworkSource() { - Config config = mock(Config.class); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn("invalid"); - - assertNull( - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), config)); - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "offline") - void startWithOfflineConfigurationSourceDisablesSystem() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } - } - - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") - void startWithInvalidConfigurationSourceDisablesSystem() { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - - try { - assertDoesNotThrow(() -> FeatureFlaggingSystem.start(sharedCommunicationObjects)); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } + assertNull(FeatureFlaggingGateway.backend()); } @Test - void initializationFailureClosesConfigurationSourceAndExposureWriter() { - ConfigurationSourceService configService = mock(ConfigurationSourceService.class); - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - - assertThrows( - IllegalStateException.class, - () -> FeatureFlaggingSystem.initialize(configService, exposureWriter)); + @WithConfig(key = FEATURE_FLAGS_ENABLED, value = "true") + void startIsIdempotent() { + final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + FeatureFlaggingSystem.start(sco); + final FeatureFlaggingGateway.Backend backend = FeatureFlaggingGateway.backend(); + assertInstanceOf(FeatureFlagsBackend.class, backend); - verify(configService).init(); - verify(configService).close(); - verify(exposureWriter).close(); + FeatureFlaggingSystem.start(sco); + assertSame(backend, FeatureFlaggingGateway.backend()); } @Test - void initializationFailureWithoutConfigurationSourceClosesExposureWriter() { - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - - assertThrows( - IllegalStateException.class, () -> FeatureFlaggingSystem.initialize(null, exposureWriter)); - - verify(exposureWriter).close(); - } - - @Test - void initializationFailureClosesConfigurationSourceWhenExposureWriterCloseFails() { - ConfigurationSourceService configService = mock(ConfigurationSourceService.class); - ExposureWriter exposureWriter = mock(ExposureWriter.class); - doThrow(new IllegalStateException("exposure init failed")).when(exposureWriter).init(); - doThrow(new IllegalArgumentException("exposure close failed")).when(exposureWriter).close(); - - assertThrows( - IllegalArgumentException.class, - () -> FeatureFlaggingSystem.initialize(configService, exposureWriter)); - - verify(configService).close(); - } - - private static SharedCommunicationObjects sharedCommunicationObjects() { - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - when(discovery.supportsEvpProxy()).thenReturn(true); - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); - return sharedCommunicationObjects(discovery); - } - - private static SharedCommunicationObjects sharedCommunicationObjects( - final DDAgentFeaturesDiscovery discovery) { - SharedCommunicationObjects sharedCommunicationObjects = mock(SharedCommunicationObjects.class); - when(sharedCommunicationObjects.featuresDiscovery(any(Config.class))).thenReturn(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get("http://localhost"); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - return sharedCommunicationObjects; + @WithConfig(key = FEATURE_FLAGS_ENABLED, value = "false") + void startDoesNothingWhenFeatureFlagsIsDisabled() { + FeatureFlaggingSystem.start(mock(SharedCommunicationObjects.class)); + assertNull(FeatureFlaggingGateway.backend()); } } diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md deleted file mode 100644 index 74bbccca2e2..00000000000 --- a/products/feature-flagging/feature-flagging-api/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# dd-openfeature - -Datadog OpenFeature Provider for Java. Implements the [OpenFeature](https://openfeature.dev/) `FeatureProvider` interface for Datadog's Feature Flags and Experimentation (FFE) product. - -Published as `com.datadoghq:dd-openfeature` on Maven Central. - -## Setup - -```xml - - com.datadoghq - dd-openfeature - ${dd-openfeature.version} - -``` - -The OpenFeature SDK (`dev.openfeature:sdk`) is included as a transitive dependency. - -### Evaluation metrics (optional) - -To enable evaluation metrics (`feature_flag.evaluations` counter), enable the Datadog Java agent's -OpenTelemetry metrics pipeline: - -```shell -DD_METRICS_OTEL_ENABLED=true -``` - -The provider records metrics through the OpenTelemetry Metrics API. Add `opentelemetry-api` if your -application does not already use the OpenTelemetry API for custom metrics: - -```xml - - io.opentelemetry - opentelemetry-api - 1.47.0 - -``` - -The OpenTelemetry SDK and OTLP exporter are not required on the application classpath. The Datadog -Java agent collects the API metric and exports it through the same OTLP pipeline as other custom OTel -metrics. - -## Usage - -```java -import datadog.trace.api.openfeature.Provider; -import dev.openfeature.sdk.OpenFeatureAPI; -import dev.openfeature.sdk.Client; - -OpenFeatureAPI api = OpenFeatureAPI.getInstance(); -api.setProviderAndWait(new Provider()); -Client client = api.getClient(); - -boolean enabled = client.getBooleanValue("my-feature", false, - new MutableContext("user-123")); -``` - -## Evaluation metrics - -When `DD_METRICS_OTEL_ENABLED=true` and the OpenTelemetry API is on the classpath, the provider -records a `feature_flag.evaluations` counter. The Datadog Java agent exports it to the Datadog -Agent's OTLP receiver using the configured OpenTelemetry metrics export interval. - -### Configuration - -Configure the OTLP endpoint and protocol using the standard Datadog Java agent OpenTelemetry metrics -settings. For example, to export metrics over OTLP/gRPC: - -```shell -DD_METRICS_OTEL_ENABLED=true -OTEL_EXPORTER_OTLP_ENDPOINT=http://:4317 -OTEL_EXPORTER_OTLP_PROTOCOL=grpc -``` - -### Metric attributes - -| Attribute | Description | -|---|---| -| `feature_flag.key` | Flag key | -| `feature_flag.result.variant` | Resolved variant key | -| `feature_flag.result.reason` | Evaluation reason (lowercased) | -| `error.type` | Error code (lowercased, only on error) | -| `feature_flag.result.allocation_key` | Allocation key (when present) | - -## Requirements - -- Java 11+ -- `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE=agentless` uses the Datadog agentless - backend. Set `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL` to a - different HTTP backend while keeping agentless delivery semantics. A bare - host uses the standard rules-based server path; a URL with a path is used as - the exact UFC endpoint. Configured URLs are opaque: the SDK does not add the - Datadog-managed `dd_env` query parameter, so custom backends must include any - required tenant or environment scope in the configured URL. The derived - Datadog-managed endpoint is - `https://ufc-server.ff-cdn./api/v2/feature-flagging/config/rules-based/server` - and expects UFC under the JSON:API `data.attributes` response member. It is - intended for supported commercial sites; use an explicit base URL elsewhere. - Agentless responses do not have an SDK-imposed payload-size limit. - `remote_config` uses the existing Agent Remote - Configuration path. `offline` is reserved for startup-provided UFC bytes; - until those bytes are implemented, no network source starts and evaluations - use defaults. diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts deleted file mode 100644 index a787e7b3fd7..00000000000 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ /dev/null @@ -1,90 +0,0 @@ -import datadog.gradle.configureCompiler -import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension - -plugins { - `java-library` - idea - id("dd-trace-java.module.distributable.api") - id("me.champeau.jmh") -} - -configure { - minJavaVersion.set(JavaVersion.VERSION_11) -} - -description = "Implementation of the OpenFeature Provider interface." - -// Set both JAR and Maven artifact name -val openFeatureArtifactId = "dd-openfeature" -base { - archivesName.set(openFeatureArtifactId) -} - -publishing { - publications.withType().configureEach { - artifactId = openFeatureArtifactId - } -} - -idea { - module { - jdkName = "11" - } -} - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(11) - } -} - -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")) - // SpanEnrichmentGate resolves FeatureFlaggingConfig at runtime. Without it on the test - // classpath the gate swallows a NoClassDefFoundError and reads as off, so the enrichment - // branch cannot be driven. - testImplementation(project(":products:feature-flagging:feature-flagging-config")) - testImplementation(project(":utils:config-utils")) - testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") - testImplementation(libs.bundles.junit5) - testImplementation(libs.bundles.mockito) - testImplementation(libs.moshi) - - // The main source set gets the bootstrap/config types as compileOnly, so the JMH source set - // needs them on its own compile and runtime classpath to drive the hook end to end. - jmhImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) - jmhImplementation(project(":products:feature-flagging:feature-flagging-config")) - jmhImplementation(project(":utils:config-utils")) -} - -jmh { - jmhVersion = libs.versions.jmh.get() - duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE - if (project.hasProperty("jmhIncludes")) { - includes = listOf(project.property("jmhIncludes").toString()) - } - if (project.hasProperty("jmhProf")) { - profilers = listOf(project.property("jmhProf").toString()) - } -} - -tasks.withType().configureEach { - configureCompiler(JavaVersion.VERSION_11) -} - -tasks.withType().configureEach { - javadocTool = javaToolchains.javadocToolFor(java.toolchain) -} - -// The dd-openfeature provider jar is not produced by the CI `build` job, so there is no reference -// artifact to compare against. Disable the release jar comparison gate registered by publish.gradle. -tasks.named("compareToReferenceJar") { - enabled = false -} diff --git a/products/feature-flagging/feature-flagging-api/gradle.lockfile b/products/feature-flagging/feature-flagging-api/gradle.lockfile deleted file mode 100644 index a2fc735b6a4..00000000000 --- a/products/feature-flagging/feature-flagging-api/gradle.lockfile +++ /dev/null @@ -1,88 +0,0 @@ -# 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-api:dependencies --write-locks -ch.qos.logback:logback-classic:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.2.13=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.javaparser:javaparser-core:3.25.6=codenarc -com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,jmhCompileClasspath,spotbugs -com.github.spotbugs:spotbugs:4.10.4=spotbugs -com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.code.findbugs:jsr305:3.0.2=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -com.google.code.gson:gson:2.14.0=spotbugs -com.google.errorprone:error_prone_annotations:2.48.0=spotbugs -com.squareup.moshi:moshi:1.11.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.squareup.okio:okio:1.17.5=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.thoughtworks.qdox:qdox:1.12.1=codenarc -commons-io:commons-io:2.21.0=spotbugs -de.thetaphi:forbiddenapis:3.11=compileClasspath,jmhCompileClasspath -dev.openfeature:sdk:1.20.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.leangen.geantyref:geantyref:1.3.16=jmhRuntimeClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-api:1.47.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -io.opentelemetry:opentelemetry-context:1.47.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jaxen:jaxen:2.0.6=spotbugs -net.bytebuddy:byte-buddy-agent:1.18.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.18.12=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -net.sf.jopt-simple:jopt-simple:5.0.4=jmh,jmhCompileClasspath,jmhRuntimeClasspath -net.sf.saxon:Saxon-HE:12.10=spotbugs -org.apache.ant:ant-antlr:1.10.14=codenarc -org.apache.ant:ant-junit:1.10.14=codenarc -org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=spotbugs -org.apache.commons:commons-math3:3.6.1=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.apache.commons:commons-text:1.15.0=spotbugs -org.apache.logging.log4j:log4j-api:2.26.1=spotbugs -org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -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=jmhRuntimeClasspath,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=jmhRuntimeClasspath,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=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt -org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt -org.jacoco:org.jacoco.core:0.8.15=jacocoAnt -org.jacoco:org.jacoco.report:0.8.15=jacocoAnt -org.junit.jupiter:junit-jupiter-api:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:5.14.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:1.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:1.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:1.14.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-core:4.4.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:4.4.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.openjdk.jmh:jmh-core:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.openjdk.jmh:jmh-generator-asm:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.openjdk.jmh:jmh-generator-bytecode:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.openjdk.jmh:jmh-generator-reflection:1.37=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.ow2.asm:asm-analysis:9.10.1=spotbugs -org.ow2.asm:asm-commons:9.10.1=jacocoAnt,spotbugs -org.ow2.asm:asm-tree:9.10.1=jacocoAnt,spotbugs -org.ow2.asm:asm-util:9.10.1=spotbugs -org.ow2.asm:asm:9.0=jmh,jmhCompileClasspath,jmhRuntimeClasspath -org.ow2.asm:asm:9.10.1=jacocoAnt,spotbugs -org.slf4j:jcl-over-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:log4j-over-slf4j:1.7.30=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.18=spotbugs -org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j -org.snakeyaml:snakeyaml-engine:2.9=jmhRuntimeClasspath,testRuntimeClasspath -org.spockframework:spock-bom:2.4-groovy-3.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.spockframework:spock-core:2.4-groovy-3.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.tabletest:tabletest-junit:1.2.2=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.tabletest:tabletest-parser:1.2.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.xmlresolver:xmlresolver:5.3.3=spotbugs -empty=annotationProcessor,jmhAnnotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java b/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java deleted file mode 100644 index e292424e53e..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/jmh/java/datadog/trace/api/openfeature/FlagEvalHookHotPathBenchmark.java +++ /dev/null @@ -1,203 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.ImmutableStructure; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Value; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.TearDown; -import org.openjdk.jmh.annotations.Warmup; -import org.openjdk.jmh.infra.Blackhole; - -/** - * Evaluation-thread benchmark for the OpenFeature hook: the inline cost a caller actually pays - * inside FlagEvalLoggingHook.finallyAfter, across context shapes. - * - *

    This is the counterpart to FlagEvaluationHotPathBenchmark in feature-flagging-lib, which - * measures the writer queue and the worker-thread aggregation. The hook and the OpenFeature context - * types live in this module, so the inline cost has to be measured here. - * - *

    The dominant inline cost under consent-on is DDEvaluator.copyPrunedContext: one bounded walk - * of the caller-owned EvaluationContext. contextCopy isolates that walk so its share of - * hookFinallyAfter is directly readable. Under consent-off the hook skips the copy entirely, which - * hookFinallyAfterConsentOff measures as the protected-path floor. - * - *

    The writer used here is a no-op that discards events, so no queue or worker cost is included. - * - *

    Run: {@code ./gradlew :products:feature-flagging:feature-flagging-api:jmh - * -PjmhIncludes=FlagEvalHookHotPathBenchmark}. - */ -@State(Scope.Benchmark) -@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(NANOSECONDS) -@Fork(value = 1) -public class FlagEvalHookHotPathBenchmark { - - /** - * Context shapes. The three 100-leaf shapes (flat/100attrs, nested/10structs_10fields, - * list/10lists_10items) carry the same leaf count under different structure, so the spread - * between them isolates shape cost from leaf count. - */ - @Param({ - "flat/0attrs", - "flat/10attrs", - "flat/100attrs", - "nested/10structs_10fields", - "list/10lists_10items" - }) - public String shape; - - private HookContext hookContext; - private FlagEvaluationDetails consentOnDetails; - private FlagEvaluationDetails consentOffDetails; - private FlagEvalLoggingHook hook; - - @Setup(Level.Trial) - public void setUp() { - final MutableContext ctx = buildContext(shape); - hookContext = - HookContext.builder() - .flagKey("bench-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(ctx) - .build(); - - consentOnDetails = details(true); - consentOffDetails = details(false); - - // Discarding writer: isolates hook-inline cost from queue mechanics. - hook = new FlagEvalLoggingHook<>(new NoOpWriter()); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @TearDown(Level.Trial) - public void tearDown() { - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - } - - /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ - @Benchmark - public void hookFinallyAfter() { - hook.finallyAfter(hookContext, consentOnDetails, Collections.emptyMap()); - } - - /** Protected-path floor: consent-off skips the context copy, leaving scalar work plus enqueue. */ - @Benchmark - public void hookFinallyAfterConsentOff() { - hook.finallyAfter(hookContext, consentOffDetails, Collections.emptyMap()); - } - - /** The bounded context copy alone - the component that scales with context shape. */ - @Benchmark - public void contextCopy(final Blackhole blackhole) { - blackhole.consume(DDEvaluator.copyPrunedContext(hookContext.getCtx())); - } - - private static FlagEvaluationDetails details(final boolean observeFullEvaluationData) { - return FlagEvaluationDetails.builder() - .flagKey("bench-flag") - .value("on-value") - .variant("on") - .reason(Reason.TARGETING_MATCH.name()) - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "alloc-1") - .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) - .addBoolean( - DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) - .build()) - .build(); - } - - private static MutableContext buildContext(final String shape) { - final MutableContext ctx = new MutableContext("bench-user"); - if ("flat/0attrs".equals(shape)) { - return ctx; - } - if ("flat/10attrs".equals(shape)) { - return addFlat(ctx, 10); - } - if ("flat/100attrs".equals(shape)) { - return addFlat(ctx, 100); - } - if ("nested/10structs_10fields".equals(shape)) { - for (int i = 0; i < 10; i++) { - final Map inner = new HashMap<>(); - for (int j = 0; j < 10; j++) { - inner.put("field" + j, new Value("value" + j)); - } - ctx.add("struct" + i, new ImmutableStructure(inner)); - } - return ctx; - } - if ("list/10lists_10items".equals(shape)) { - for (int i = 0; i < 10; i++) { - final List items = new ArrayList<>(10); - for (int j = 0; j < 10; j++) { - items.add(new Value("value" + j)); - } - ctx.add("list" + i, items); - } - return ctx; - } - throw new IllegalArgumentException("unknown benchmark shape: " + shape); - } - - private static MutableContext addFlat(final MutableContext ctx, final int count) { - for (int i = 0; i < count; i++) { - ctx.add("field" + i, "value" + i); - } - return ctx; - } - - /** Discards events so only hook-inline work is measured. */ - private static final class NoOpWriter implements FlagEvaluationWriter { - @Override - public void enqueue(final FlagEvalEvent event) {} - - @Override - public boolean hasCapacityForEnqueue() { - return true; - } - - @Override - public void countPreQueueOverflow() {} - - @Override - public void countContextTruncated(final String reason) {} - - @Override - public void start() {} - - @Override - public void close() {} - } -} 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 deleted file mode 100644 index 9bc55fe63e9..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ /dev/null @@ -1,1040 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Arrays.asList; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; -import datadog.trace.api.featureflag.ufc.v1.Rule; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.ErrorCode; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.ImmutableStructure; -import dev.openfeature.sdk.ProviderEvaluation; -import dev.openfeature.sdk.Reason; -import dev.openfeature.sdk.Structure; -import dev.openfeature.sdk.Value; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.util.AbstractMap; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Deque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.IdentityHashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { - - private static final Logger log = LoggerFactory.getLogger(DDEvaluator.class); - private static final Set> SUPPORTED_RESOLUTION_TYPES = - new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); - - static final AtomicBoolean SPLIT_SERIAL_ID_SUPPORTED = - new AtomicBoolean(splitSerialIdSupported(Split.class)); - - static final AtomicBoolean USE_LEGACY_EXPOSURE_API = - new AtomicBoolean( - !(SPLIT_SERIAL_ID_SUPPORTED.get() && exposureSerialIdSupported(ExposureEvent.class))); - - static boolean splitSerialIdSupported(final Class splitClass) { - try { - return splitClass.getField("serialId").getType() == Integer.class; - } catch (final NoSuchFieldException | LinkageError | RuntimeException e) { - log.warn( - "Feature flag serial ID reporting is unavailable with the installed Datadog Java " - + "agent, which does not carry a serial id on the flag configuration. Upgrade " - + "dd-java-agent to enable holdout attribution."); - log.debug("Unable to access the flag configuration serial ID", e); - return false; - } - } - - static boolean exposureSerialIdSupported(final Class eventClass) { - try { - eventClass.getConstructor( - long.class, - datadog.trace.api.featureflag.exposure.Allocation.class, - datadog.trace.api.featureflag.exposure.Flag.class, - datadog.trace.api.featureflag.exposure.Variant.class, - Subject.class, - Integer.class); - return true; - } catch (final NoSuchMethodException | LinkageError | RuntimeException e) { - log.warn( - "Feature flag exposure serial ID reporting is unavailable with the installed " - + "Datadog Java agent. Exposures are still reported, without the serial id, and " - + "span enrichment is unaffected. Upgrade dd-java-agent to enable holdout " - + "attribution on exposures."); - log.debug("Unable to access the exposure serial ID constructor", e); - return false; - } - } - - /** - * Maximum evaluation-context nesting depth captured on the hot path. Recursion runs on the - * caller's evaluation thread over a caller-owned Value tree, so an arbitrarily deep - * list/structure would overflow that thread's stack - and a StackOverflowError is not caught by - * the LinkageError | Exception guards that keep telemetry from breaking an evaluation. Values - * below the limit are truncated to null, the same way the cycle guard truncates. Kept aligned - * with the cross-SDK RFC target (4). - */ - static final int MAX_SNAPSHOT_DEPTH = 4; - - /** - * Maximum number of top-level context fields retained by copyPrunedContext. Bounds the width of - * the caller-supplied context and, transitively, the size of every FlagEvalEvent sitting in the - * async hand-off queue. Kept aligned with the cross-SDK RFC. - */ - static final int MAX_CONTEXT_FIELDS = 256; - - /** - * Maximum character length for a single context KEY retained by copyPrunedContext. Keys are - * stored verbatim in every full-tier bucket, so an unbounded key size would let a single caller - * inflate steady-state heap use. Longer keys cause the field to be skipped. - */ - static final int MAX_KEY_LENGTH = 256; - - /** - * Maximum character length for a single context string VALUE retained by copyPrunedContext. - * Longer values cause the field to be skipped (matches previous pruneContext behavior). - * Non-string scalars are not length-bounded. - */ - static final int MAX_VALUE_LENGTH = 256; - - /** - * Maximum number of elements walked per list encountered during copyPrunedContext. Bounds the - * fan-out of a single wide list at capture time so one caller cannot inflate the hot path with a - * huge but shallow structure. Elements past the limit are skipped. - */ - static final int MAX_LIST_ELEMENTS = 256; - - /** - * Maximum number of properties walked per structure encountered during copyPrunedContext. Same - * intent as MAX_LIST_ELEMENTS for structures. Properties past the limit are skipped. - */ - static final int MAX_STRUCTURE_PROPERTIES = 256; - - // 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"; - - // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing - // key = non-DD provider; the hook falls back to false (fail-closed). - static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; - - // 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); - - public DDEvaluator(final Runnable configCallback) { - this.configCallback = configCallback; - } - - @Override - public boolean initialize( - final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { - FeatureFlaggingGateway.activate(); - FeatureFlaggingGateway.addConfigListener(this); - return initializationLatch.await(timeout, unit) || hasConfiguration(); - } - - @Override - public boolean hasConfiguration() { - return configuration.get() != null; - } - - @Override - public void shutdown() { - FeatureFlaggingGateway.removeConfigListener(this); - } - - @Override - public void accept(final ServerConfiguration config) { - configuration.set(config); - if (config != null) { - initializationLatch.countDown(); - configCallback.run(); - } else if (initializationLatch.getCount() == 0) { - configCallback.run(); - } - } - - @Override - public ProviderEvaluation evaluate( - final Class target, - final String key, - final T defaultValue, - final EvaluationContext context) { - // Snapshot the config once and thread observeFullEvaluationData through every - // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's - // config and cannot drift on a concurrent Remote Config swap. - final ServerConfiguration config = configuration.get(); - // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without - // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. - final boolean observeFullEvaluationData = - config != null && Boolean.TRUE.equals(config.observeFullEvaluationData); - try { - if (config == null) { - return error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData); - } - - if (context == null) { - return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); - } - - final Flag flag = config.flags.get(key); - if (flag == null) { - if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { - return error( - defaultValue, - ErrorCode.PARSE_ERROR, - "invalid configuration for flag " + key, - observeFullEvaluationData); - } - return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); - } - - if (!flag.enabled) { - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DISABLED.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } - - if (flag.allocations == null) { - return error( - defaultValue, - ErrorCode.GENERAL, - "Missing allocations for flag " + key, - observeFullEvaluationData); - } - - final Instant now = Instant.now(); - final long evalTimestampMs = now.toEpochMilli(); - final String targetingKey = context.getTargetingKey(); - - for (final Allocation allocation : flag.allocations) { - if (!isAllocationActive(allocation, now)) { - continue; - } - - if (!isEmpty(allocation.rules)) { - if (!evaluateRules(allocation.rules, context)) { - continue; - } - } - - if (!isEmpty(allocation.splits)) { - for (final Split split : allocation.splits) { - if (isEmpty(split.shards)) { - return resolveVariant( - target, - key, - defaultValue, - flag, - split.variationKey, - allocation, - split, - context, - evalTimestampMs, - observeFullEvaluationData); - } else { - if (targetingKey == null) { - return error( - defaultValue, ErrorCode.TARGETING_KEY_MISSING, null, observeFullEvaluationData); - } - // To match a split, subject must match ALL underlying shards - boolean allShardsMatch = true; - for (final Shard shard : split.shards) { - if (!matchesShard(shard, targetingKey)) { - allShardsMatch = false; - break; - } - } - if (allShardsMatch) { - return resolveVariant( - target, - key, - defaultValue, - flag, - split.variationKey, - allocation, - split, - context, - evalTimestampMs, - observeFullEvaluationData); - } - } - } - } - } - - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.DEFAULT.name()) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } catch (final PatternSyntaxException e) { - return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); - } catch (final NumberFormatException e) { - return error( - defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); - } catch (final Exception e) { - return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); - } - } - - private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { - return ImmutableMetadata.builder() - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) - .build(); - } - - private static ProviderEvaluation error( - final T defaultValue, - final ErrorCode code, - final String errorMessage, - final boolean observeFullEvaluationData) { - // Under consent-off the errorMessage is dropped: exception messages from the outer catch blocks - // (NumberFormatException, generic Exception) can echo raw evaluation-context values, so they - // must never reach any consumer of ProviderEvaluation.getErrorMessage() — not just our own - // wire hook. Downstream (FlagEvalLoggingHook) falls back to ErrorCode.name(), so operators - // still get a stable signal like "TYPE_MISMATCH". - return ProviderEvaluation.builder() - .value(defaultValue) - .reason(Reason.ERROR.name()) - .errorCode(code) - .errorMessage(observeFullEvaluationData ? errorMessage : null) - .flagMetadata(consentMetadata(observeFullEvaluationData)) - .build(); - } - - private static boolean isEmpty(final List list) { - return list == null || list.isEmpty(); - } - - static boolean isAllocationActive(final Allocation allocation, final Instant now) { - final Instant startDate = allocation.startAtInstant(); - if (startDate != null && now.isBefore(startDate)) { - return false; - } - - final Instant endDate = allocation.endAtInstant(); - if (endDate != null && now.isAfter(endDate)) { - return false; - } - - return true; - } - - private static boolean evaluateRules(final List rules, final EvaluationContext context) { - for (final Rule rule : rules) { - if (isEmpty(rule.conditions)) { - continue; - } - - boolean allConditionsMatch = true; - for (final ConditionConfiguration condition : rule.conditions) { - if (!evaluateCondition(condition, context)) { - allConditionsMatch = false; - break; - } - } - - if (allConditionsMatch) { - return true; - } - } - return false; - } - - private static boolean evaluateCondition( - final ConditionConfiguration condition, final EvaluationContext context) { - if (condition.operator == ConditionOperator.IS_NULL) { - final Object value = resolveAttribute(condition.attribute, context); - boolean isNull = value == null; - // condition.value determines if we're checking for null (true) or not null (false) - boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true; - return isNull == expectedNull; - } - - final Object attributeValue = resolveAttribute(condition.attribute, context); - if (attributeValue == null) { - return false; - } - - switch (condition.operator) { - case MATCHES: - return matchesRegex(attributeValue, condition.value); - case NOT_MATCHES: - return !matchesRegex(attributeValue, condition.value); - case ONE_OF: - return isOneOf(attributeValue, condition.value); - case NOT_ONE_OF: - return !isOneOf(attributeValue, condition.value); - case GTE: - return compareNumber(attributeValue, condition.value, (a, b) -> a >= b); - case GT: - return compareNumber(attributeValue, condition.value, (a, b) -> a > b); - case LTE: - return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); - case LT: - return compareNumber(attributeValue, condition.value, (a, b) -> a < b); - case SEMVER_EQ: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o == 0); - case SEMVER_NEQ: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o != 0); - case SEMVER_LT: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o < 0); - case SEMVER_LTE: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o <= 0); - case SEMVER_GT: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o > 0); - case SEMVER_GTE: - return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o >= 0); - default: - return false; - } - } - - private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { - // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), - // which maps it to ErrorCode.PARSE_ERROR. - final Pattern pattern = Pattern.compile(normalizeRegex(String.valueOf(conditionValue))); - return pattern.matcher(String.valueOf(attributeValue)).find(); - } - - private static String normalizeRegex(final String regex) { - return regex - .replace("[:alnum:]", "\\p{Alnum}") - .replace("[:alpha:]", "\\p{Alpha}") - .replace("[:digit:]", "\\p{Digit}") - .replace("[:lower:]", "\\p{Lower}") - .replace("[:upper:]", "\\p{Upper}") - .replace("[:space:]", "\\p{Space}"); - } - - private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { - if (!(conditionValue instanceof Iterable)) { - return false; - } - for (final Object value : (Iterable) conditionValue) { - if (valuesEqual(attributeValue, value)) { - return true; - } - } - return false; - } - - private static boolean valuesEqual(final Object a, final Object b) { - if (Objects.equals(a, b)) { - return true; - } - - if (a instanceof Number || b instanceof Number) { - return compareNumber(a, b, (first, second) -> first == second); - } - - return String.valueOf(a).equals(String.valueOf(b)); - } - - private static boolean compareNumber( - final Object attributeValue, final Object conditionValue, NumberComparator comparator) { - final double a = mapValue(Double.class, attributeValue); - final double b = mapValue(Double.class, conditionValue); - return comparator.compare(a, b); - } - - /** - * Evaluates a semantic version comparison operator. The attribute value must be a string that is - * a valid semantic version, and the comparand must have been pre-parsed during configuration - * validation. If either is missing or invalid, the condition does not match. - */ - private static boolean evaluateSemverCondition( - final Object attributeValue, - final ParsedSemver comparand, - final SemverComparator comparator) { - if (!(attributeValue instanceof String) || comparand == null) { - return false; - } - final ParsedSemver parsedAttribute = ParsedSemver.parse((String) attributeValue); - if (parsedAttribute == null) { - return false; - } - return comparator.compare(ParsedSemver.compare(parsedAttribute, comparand)); - } - - private static boolean matchesShard(final Shard shard, final String targetingKey) { - // The bootstrap model preserves its int ABI, so UFC uint32 values are stored as raw bits. - // Convert before arithmetic and comparison to retain their unsigned wire semantics. - final long totalShards = Integer.toUnsignedLong(shard.totalShards); - final long assignedShard = getShard(shard.salt, targetingKey, totalShards); - for (final ShardRange range : shard.ranges) { - final long rangeStart = Integer.toUnsignedLong(range.start); - final long rangeEnd = Integer.toUnsignedLong(range.end); - if (assignedShard >= rangeStart && assignedShard < rangeEnd) { - return true; - } - } - return false; - } - - private static long getShard( - final String salt, final String targetingKey, final long totalShards) { - final String hashKey = salt + "-" + targetingKey; - final String md5Hash = getMD5Hash(hashKey); - final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length())); - final long intFromHash = Long.parseLong(first8Chars, 16); - return intFromHash % totalShards; - } - - private static String getMD5Hash(final String input) { - try { - final MessageDigest md = MessageDigest.getInstance("MD5"); - final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); - final StringBuilder hexString = new StringBuilder(); - for (byte b : hashBytes) { - final String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) { - hexString.append('0'); - } - hexString.append(hex); - } - return hexString.toString(); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("MD5 algorithm not available", e); - } - } - - private static ProviderEvaluation resolveVariant( - final Class target, - final String key, - final T defaultValue, - final Flag flag, - final String variationKey, - final Allocation allocation, - final Split split, - final EvaluationContext context, - final long evalTimestampMs, - final boolean observeFullEvaluationData) { - final Variant variant = flag.variations.get(variationKey); - if (variant == null) { - return error( - defaultValue, - ErrorCode.GENERAL, - "Variant not found for: " + variationKey, - observeFullEvaluationData); - } - - if (!isTypeCompatible(target, flag.variationType)) { - return error( - defaultValue, - ErrorCode.TYPE_MISMATCH, - "Requested type " - + target.getSimpleName() - + " does not match flag variationType " - + flag.variationType.name(), - observeFullEvaluationData); - } - - final T mappedValue; - try { - mappedValue = mapValue(target, variant.value); - } catch (final NumberFormatException e) { - return error( - defaultValue, - ErrorCode.PARSE_ERROR, - "Variant '" - + variant.key - + "' value does not match declared type " - + flag.variationType.name() - + ": " - + e.getMessage(), - observeFullEvaluationData); - } - - // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, - // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". - final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = - ImmutableMetadata.builder() - .addString("flagKey", flag.key) - .addString("variationType", flag.variationType.name()) - .addString("allocationKey", allocation.key) - .addLong("__dd_eval_timestamp_ms", evalTimestampMs) - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); - // 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_SERIAL_ID_SUPPORTED.get() && 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) - .reason( - !isEmpty(allocation.rules) - ? Reason.TARGETING_MATCH.name() - : allocation.startAt != null || allocation.endAt != null - ? Reason.DEFAULT.name() - : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) - .variant(variant.key) - .flagMetadata(metadataBuilder.build()) - .build(); - final boolean doLog = allocation.doLog != null && allocation.doLog; - if (doLog) { - dispatchExposure(key, result, context, split); - } - return result; - } - - private static Object resolveAttribute(final String name, final EvaluationContext context) { - // Special handling for "id" attribute: if not explicitly provided, use targeting key - if ("id".equals(name) && !context.keySet().contains(name)) { - return context.getTargetingKey(); - } - final Value resolved = context.getValue(name); - return context.convertValue(resolved); - } - - private static boolean isTypeCompatible(final Class target, final ValueType variationType) { - if (variationType == null) { - return true; // No type info — allow any - } - switch (variationType) { - case BOOLEAN: - return target == Boolean.class; - case STRING: - return target == String.class; - case INTEGER: - return target == Integer.class; - case NUMERIC: - return target == Double.class; - case JSON: - return target == Value.class; - default: - return true; // Unknown types pass through — mapValue errors caught as GENERAL - } - } - - @SuppressWarnings("unchecked") - static T mapValue(final Class target, final Object value) { - if (value == null) { - return null; - } - if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { - throw new IllegalArgumentException("Type not supported: " + target); - } - if (target.isInstance(value)) { - return target.cast(value); - } - if (target == String.class) { - return (T) String.valueOf(value); - } - if (target == Boolean.class) { - if (value instanceof Number) { - return (T) (Boolean) (parseDouble(value) != 0); - } - return (T) Boolean.valueOf(value.toString()); - } - if (target == Integer.class) { - final Double number = parseDouble(value); - return (T) (Integer) number.intValue(); - } - if (target == Double.class) { - final Double number = parseDouble(value); - return (T) number; - } - return (T) Value.objectToValue(value); - } - - private static Double parseDouble(final Object value) { - if (value instanceof Number) { - return ((Number) value).doubleValue(); - } - return Double.parseDouble(String.valueOf(value)); - } - - private static void dispatchExposure( - final String flag, - final ProviderEvaluation evaluation, - final EvaluationContext context, - final Split split) { - final String allocationKey = allocationKey(evaluation); - final String variantKey = evaluation.getVariant(); - if (allocationKey == null || variantKey == null) { - return; - } - final long timestamp = System.currentTimeMillis(); - // Exposure types share names with the imported UFC Allocation, Flag, and Variant types. - final datadog.trace.api.featureflag.exposure.Allocation allocation = - new datadog.trace.api.featureflag.exposure.Allocation(allocationKey); - final datadog.trace.api.featureflag.exposure.Flag exposureFlag = - new datadog.trace.api.featureflag.exposure.Flag(flag); - final datadog.trace.api.featureflag.exposure.Variant variant = - new datadog.trace.api.featureflag.exposure.Variant(variantKey); - final Subject subject = new Subject(context.getTargetingKey(), flattenContext(context)); - - final ExposureEvent event = - USE_LEGACY_EXPOSURE_API.get() - ? new ExposureEvent(timestamp, allocation, exposureFlag, variant, subject) - : new ExposureEvent( - timestamp, allocation, exposureFlag, variant, subject, split.serialId); - FeatureFlaggingGateway.dispatch(event); - } - - private static String allocationKey(final ProviderEvaluation resolution) { - final ImmutableMetadata meta = resolution.getFlagMetadata(); - return meta == null ? null : meta.getString("allocationKey"); - } - - static AbstractMap flattenContext(final EvaluationContext context) { - return flattenValues(snapshotValues(context)); - } - - static Map snapshotValues(final EvaluationContext context) { - final HashMap values = new HashMap<>(); - final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); - for (final String key : context.keySet()) { - values.put(key, snapshotValue(context.getValue(key), seenContainers, 0)); - } - return values; - } - - private static Value snapshotValue( - final Value value, final Set seenContainers, final int depth) { - if (value == null) { - return null; - } else if (value.isNull()) { - return new Value(); - } else if (value.isBoolean()) { - return new Value(value.asBoolean()); - } else if (value.isNumber()) { - final Object number = value.asObject(); - return number instanceof Integer - ? new Value((Integer) number) - : new Value(((Number) number).doubleValue()); - } else if (value.isString()) { - return new Value(value.asString()); - } else if (value.isInstant()) { - return new Value(value.asInstant()); - } else if (value.isList()) { - final List list = value.asList(); - if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(list)) { - return new Value(); - } - final List snapshot = new ArrayList<>(list.size()); - for (final Value item : list) { - snapshot.add(snapshotValue(item, seenContainers, depth + 1)); - } - seenContainers.remove(list); - return new Value(Collections.unmodifiableList(snapshot)); - } else if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (depth >= MAX_SNAPSHOT_DEPTH || !seenContainers.add(structure)) { - return new Value(); - } - final Map snapshot = new HashMap<>(); - for (final String key : structure.keySet()) { - snapshot.put(key, snapshotValue(structure.getValue(key), seenContainers, depth + 1)); - } - seenContainers.remove(structure); - return new Value(new ImmutableStructure(snapshot)); - } - throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); - } - - static AbstractMap flattenValues(final Map values) { - final HashMap result = new HashMap<>(); - final Set seenContainers = Collections.newSetFromMap(new IdentityHashMap<>()); - for (final Map.Entry root : values.entrySet()) { - final Deque deque = new LinkedList<>(); - deque.push(new FlattenEntry(root.getKey(), root.getValue())); - while (!deque.isEmpty()) { - final FlattenEntry entry = deque.pop(); - final Value value = entry.value; - if (value == null) { - result.put(entry.key, null); - } else if (value.isList()) { - final List list = value.asList(); - if (seenContainers.add(list)) { - for (int i = 0; i < list.size(); i++) { - deque.push(new FlattenEntry(entry.key + "[" + i + "]", list.get(i))); - } - } - } else if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (seenContainers.add(structure)) { - for (final String property : structure.keySet()) { - deque.push( - new FlattenEntry(entry.key + "." + property, structure.getValue(property))); - } - } - } else { - result.put(entry.key, convertValue(value)); - } - } - } - return result; - } - - private static Object convertValue(final Value value) { - if (value == null || value.isNull()) { - return null; - } else if (value.isBoolean()) { - return value.asBoolean(); - } else if (value.isNumber()) { - return value.asObject(); - } else if (value.isString()) { - return value.asString(); - } else if (value.isInstant()) { - return value.asInstant().toString(); - } - throw new IllegalArgumentException("Unsupported OpenFeature value type: " + value); - } - - // Reason-code bitmask constants used by copyPrunedContext to track which caps fired. - static final int REASON_MAX_CONTEXT_FIELDS = 1; - static final int REASON_MAX_KEY_LENGTH = 1 << 1; - static final int REASON_MAX_VALUE_LENGTH = 1 << 2; - static final int REASON_MAX_LIST_ELEMENTS = 1 << 3; - static final int REASON_MAX_STRUCTURE_PROPERTIES = 1 << 4; - static final int REASON_MAX_SNAPSHOT_DEPTH = 1 << 5; - static final int REASON_CYCLE = 1 << 6; - - /** Sorted reason-code strings, indexed by their bit position in the bitmask. */ - private static final String[] REASON_NAMES = { - "max_context_fields", - "max_key_length", - "max_value_length", - "max_list_elements", - "max_structure_properties", - "max_snapshot_depth", - "cycle", - }; - - /** - * Builds the sorted, comma-separated reason tag string from a bitmask of fired reason codes. - * Returns null when no reason bit is set (no truncation occurred). The returned string is ready - * to use directly as the "reason:..." tag value. - */ - static String truncationReasonTag(final int reasonMask) { - if (reasonMask == 0) { - return null; - } - final StringBuilder sb = new StringBuilder(); - for (int bit = 0; bit < REASON_NAMES.length; bit++) { - if ((reasonMask & (1 << bit)) != 0) { - if (sb.length() > 0) { - sb.append(','); - } - sb.append(REASON_NAMES[bit]); - } - } - return sb.toString(); - } - - /** - * Result of copyPrunedContext: the pruned attribute map plus an optional reason tag describing - * which caps fired during the walk. truncatedReason is null when no truncation occurred, so - * callers can skip the telemetry path with a single null check. - */ - static final class CopyResult { - final Map attrs; - - /** Non-null when at least one cap fired; ready to use as the "reason:..." tag value. */ - final String truncatedReason; - - CopyResult(final Map attrs, final String truncatedReason) { - this.attrs = attrs; - this.truncatedReason = truncatedReason; - } - } - - /** - * Single-pass bounded copy of a caller-owned EvaluationContext into the flattened, pruned map - * stored on a FlagEvalEvent and later canonicalized by the aggregator. - * - *

    Every retained-size dimension is capped inline so the hot path performs work proportional to - * what is kept, never to what the caller supplied: MAX_CONTEXT_FIELDS - stop iterating the - * top-level context past this many retained fields MAX_KEY_LENGTH - skip fields whose flattened - * key exceeds this length (also enforced on every path segment produced by descending into - * lists/structures) MAX_VALUE_LENGTH - skip string values exceeding this length MAX_LIST_ELEMENTS - * - stop iterating a list past this many elements MAX_STRUCTURE_PROPERTIES - stop iterating a - * structure past this many properties MAX_SNAPSHOT_DEPTH - stop descending into lists/structures - * past this depth Cycle guard - identity-tracked containers currently on the recursion stack are - * treated as leaves - * - *

    All numeric limits are named constants so they can be tuned independently. - * - *

    Returns a CopyResult whose attrs is an empty map for null/empty input and whose - * truncatedReason is non-null when at least one cap fired. The returned map is a plain HashMap; - * canonical-key sorting happens once in the aggregator, off the hot path. - */ - static CopyResult copyPrunedContext(final EvaluationContext context) { - if (context == null) { - return new CopyResult(Collections.emptyMap(), null); - } - final Set keys = context.keySet(); - if (keys.isEmpty()) { - return new CopyResult(Collections.emptyMap(), null); - } - final HashMap out = new HashMap<>(); - final Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); - final int[] reasonMask = {0}; - for (final String key : keys) { - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; - } - if (EvaluationContext.TARGETING_KEY.equals(key)) { - continue; - } - copyPrunedValue(out, key, context.getValue(key), seen, 0, reasonMask); - } - final Map attrs = out.isEmpty() ? Collections.emptyMap() : out; - return new CopyResult(attrs, truncationReasonTag(reasonMask[0])); - } - - private static void copyPrunedValue( - final Map out, - final String key, - final Value value, - final Set seen, - final int depth, - final int[] reasonMask) { - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - return; - } - if (key.length() > MAX_KEY_LENGTH) { - reasonMask[0] |= REASON_MAX_KEY_LENGTH; - return; - } - if (value == null || value.isNull()) { - out.put(key, null); - return; - } - if (value.isString()) { - final String s = value.asString(); - if (s.length() > MAX_VALUE_LENGTH) { - reasonMask[0] |= REASON_MAX_VALUE_LENGTH; - return; - } - out.put(key, s); - return; - } - if (value.isBoolean() || value.isNumber() || value.isInstant()) { - out.put(key, convertValue(value)); - return; - } - if (value.isList()) { - final List list = value.asList(); - if (depth >= MAX_SNAPSHOT_DEPTH) { - reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; - return; - } - if (!seen.add(list)) { - reasonMask[0] |= REASON_CYCLE; - return; - } - if (list.size() > MAX_LIST_ELEMENTS) { - reasonMask[0] |= REASON_MAX_LIST_ELEMENTS; - } - final int limit = Math.min(list.size(), MAX_LIST_ELEMENTS); - for (int i = 0; i < limit; i++) { - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; - } - copyPrunedValue(out, key + "[" + i + "]", list.get(i), seen, depth + 1, reasonMask); - } - seen.remove(list); - return; - } - if (value.isStructure()) { - final Structure structure = value.asStructure(); - if (depth >= MAX_SNAPSHOT_DEPTH) { - reasonMask[0] |= REASON_MAX_SNAPSHOT_DEPTH; - return; - } - if (!seen.add(structure)) { - reasonMask[0] |= REASON_CYCLE; - return; - } - int walked = 0; - for (final String property : structure.keySet()) { - if (walked >= MAX_STRUCTURE_PROPERTIES) { - reasonMask[0] |= REASON_MAX_STRUCTURE_PROPERTIES; - break; - } - if (out.size() >= MAX_CONTEXT_FIELDS) { - reasonMask[0] |= REASON_MAX_CONTEXT_FIELDS; - break; - } - walked++; - copyPrunedValue( - out, key + "." + property, structure.getValue(property), seen, depth + 1, reasonMask); - } - seen.remove(structure); - } - } - - @FunctionalInterface - private interface NumberComparator { - boolean compare(double a, double b); - } - - @FunctionalInterface - private interface SemverComparator { - boolean compare(int ordering); - } - - private static class FlattenEntry { - private final String key; - private final Value value; - - private FlattenEntry(final String key, final Value value) { - this.key = key; - this.value = value; - } - } -} 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 deleted file mode 100644 index 64739f0ad39..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/SpanEnrichmentGate.java +++ /dev/null @@ -1,25 +0,0 @@ -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/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java deleted file mode 100644 index 2e5a4c483bf..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorSpanEnrichmentForkedTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package datadog.trace.api.openfeature; - -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; - -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Split; -import datadog.trace.api.featureflag.ufc.v1.ValueType; -import datadog.trace.api.featureflag.ufc.v1.Variant; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.MutableContext; -import dev.openfeature.sdk.ProviderEvaluation; -import java.util.HashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -/** - * Drives the span-enrichment branch of {@link DDEvaluator}, which the ordinary test task cannot - * reach: the gate is read once into a static final field at class load. The property is set here - * rather than through {@code @WithConfig} because that extension rewrites {@code Config.INSTANCE}, - * which this module does not use, and the forked task gives this class its own JVM where nothing - * has loaded the evaluator yet. - */ -class DDEvaluatorSpanEnrichmentForkedTest { - - static { - System.setProperty("dd.experimental.flagging.provider.span.enrichment.enabled", "true"); - } - - @Test - void enrichmentMetadataCarriesTheSplitSerialId() { - final ProviderEvaluation result = evaluate(340132); - - assertNotNull( - result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG), - "span enrichment must be on, or the assertions below pass vacuously"); - assertEquals( - Integer.valueOf(340132), - result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } - - /** - * Simulates legacy exposure support while Split.serialId remains available. Falling back to the - * five-argument exposure constructor must not suppress the serial id in enrichment metadata. - */ - @Test - void enrichmentMetadataSurvivesAnAgentWithoutTheExposureConstructor() { - final boolean previous = DDEvaluator.USE_LEGACY_EXPOSURE_API.getAndSet(true); - try { - assertEquals( - Integer.valueOf(340132), - evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } finally { - DDEvaluator.USE_LEGACY_EXPOSURE_API.set(previous); - } - } - - @Test - void enrichmentMetadataOmitsTheSerialIdWhenTheAgentSplitHasNoField() { - final boolean previous = DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.getAndSet(false); - try { - assertNull( - evaluate(340132).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } finally { - DDEvaluator.SPLIT_SERIAL_ID_SUPPORTED.set(previous); - } - } - - @Test - void enrichmentMetadataOmitsTheSerialIdWhenTheSplitHasNone() { - assertNull(evaluate(null).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); - } - - private static ProviderEvaluation evaluate(final Integer serialId) { - final Map variations = new HashMap<>(); - variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), serialId); - final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); - final Map flags = new HashMap<>(); - flags.put( - "target", - new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); - - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, flags)); - - final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); - return evaluator.evaluate(Integer.class, "target", 23, ctx); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts b/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts index 94f08ae55fe..3a60a4599da 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-bootstrap/build.gradle.kts @@ -4,30 +4,7 @@ plugins { id("dd-trace-java.module.bootstrap-component") } -description = "Feature flagging remote common module (bootstrap classloader)" - -extra["excludedClassesCoverage"] = listOf( - // Feature lags POJOs - "datadog.trace.api.featureflag.exposure.Allocation", - "datadog.trace.api.featureflag.exposure.ExposureEvent", - "datadog.trace.api.featureflag.exposure.ExposuresRequest", - "datadog.trace.api.featureflag.exposure.Flag", - "datadog.trace.api.featureflag.exposure.Subject", - "datadog.trace.api.featureflag.exposure.Variant", - "datadog.trace.api.featureflag.ufc.v1.Allocation", - "datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration", - "datadog.trace.api.featureflag.ufc.v1.ConditionOperator", - "datadog.trace.api.featureflag.ufc.v1.Environment", - "datadog.trace.api.featureflag.ufc.v1.Feature", - "datadog.trace.api.featureflag.ufc.v1.Flag", - "datadog.trace.api.featureflag.ufc.v1.Rule", - "datadog.trace.api.featureflag.ufc.v1.ServerConfiguration", - "datadog.trace.api.featureflag.ufc.v1.Shard", - "datadog.trace.api.featureflag.ufc.v1.ShardRange", - "datadog.trace.api.featureflag.ufc.v1.Split", - "datadog.trace.api.featureflag.ufc.v1.ValueType", - "datadog.trace.api.featureflag.ufc.v1.Variant", -) +description = "Feature flagging bridge between the SDK instrumentation and the agent (bootstrap classloader)" dependencies { testImplementation(libs.bundles.junit5) diff --git a/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile b/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile index f6a80637793..425897f716a 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-bootstrap/gradle.lockfile @@ -30,7 +30,7 @@ org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +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 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 2a823bd32ef..1f1cfcaca76 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 @@ -1,131 +1,102 @@ package datadog.trace.api.featureflag; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicReference; +import java.io.IOException; import java.util.function.Consumer; +import javax.annotation.Nullable; -public abstract class FeatureFlaggingGateway { - - public interface ConfigListener extends Consumer {} - - public interface ActivationListener { - void activate(); - } - - public interface ExposureListener extends Consumer {} - - public interface SpanEnrichmentListener extends Consumer {} - - private static final List CONFIG_LISTENERS = new CopyOnWriteArrayList<>(); - private static final List ACTIVATION_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<>(); - - /** - * The active EVP flagevaluation writer. Registered by {@code FlagEvaluationWriterImpl.start()} - * when the killswitch {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED} is on (default). Read by - * {@code FlagEvalLoggingHook} to route evaluations into the two-tier aggregator. {@code null} - * when the EVP path is disabled. - */ - private static final AtomicReference FLAG_EVAL_WRITER = - new AtomicReference<>(); - - private static volatile boolean flagEvalEnqueueEnabled = true; +/** + * Bridges the Feature Flags SDK instrumentation helpers to the agent Feature Flags backend. + * + *

    Both sides ship in the agent jar, so this contract is internal to the agent and only carries + * JDK types. The cross-version contract with the SDK is owned by the SDK and checked by muzzle. + */ +public final class FeatureFlaggingGateway { + private static volatile Backend backend; private FeatureFlaggingGateway() {} - public static void addConfigListener(final ConfigListener listener) { - CONFIG_LISTENERS.add(listener); - final ServerConfiguration current = CURRENT_CONFIG.get(); - if (current != null) { - listener.accept(current); - } - } - - public static void removeConfigListener(final ConfigListener listener) { - CONFIG_LISTENERS.remove(listener); - } - - public static void dispatch(final ServerConfiguration config) { - CURRENT_CONFIG.set(config); - CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); - } - - public static void addActivationListener(final ActivationListener listener) { - ACTIVATION_LISTENERS.add(listener); - } - - public static void removeActivationListener(final ActivationListener listener) { - ACTIVATION_LISTENERS.remove(listener); - } - - /** Signals that application code initialized the Datadog OpenFeature provider. */ - public static void activate() { - ACTIVATION_LISTENERS.forEach(ActivationListener::activate); - } - - public static void addExposureListener(final ExposureListener listener) { - EXPOSURE_LISTENERS.add(listener); - } - - public static void removeExposureListener(final ExposureListener listener) { - EXPOSURE_LISTENERS.remove(listener); - } - - public static void dispatch(final ExposureEvent event) { - EXPOSURE_LISTENERS.forEach(listener -> listener.accept(event)); - } - /** - * Registers the active EVP flagevaluation writer. Called by {@code - * FlagEvaluationWriterImpl.start()} when the feature is enabled. Replaces any previously - * registered writer. + * Registers the agent backend. * - * @param writer the writer to register, or {@code null} to deregister - */ - public static void setFlagEvalWriter(final FlagEvaluationWriter writer) { - FLAG_EVAL_WRITER.set(writer); - } - - /** - * Enables or disables enqueueing EVP flagevaluation events on the OpenFeature hook path. This is - * populated from {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED} at feature-flagging startup and - * cleared during shutdown before the writer drains. + * @param registered the backend, or {@code null} to unregister it. */ - public static void setFlagEvaluationEnqueueEnabled(final boolean enabled) { - flagEvalEnqueueEnabled = enabled; + public static void register(@Nullable final Backend registered) { + backend = registered; } /** - * Returns the active EVP flagevaluation writer, or {@code null} when disabled (killswitch off or - * not yet started). + * @return the agent backend, or {@code null} if Feature Flags is not started. */ - public static FlagEvaluationWriter getFlagEvalWriter() { - return FLAG_EVAL_WRITER.get(); - } - - /** Returns whether EVP flagevaluation hook events may be enqueued. */ - public static boolean isFlagEvaluationEnqueueEnabled() { - return flagEvalEnqueueEnabled; - } - - 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)); + @Nullable + public static Backend backend() { + return backend; + } + + /** The agent services exposed to the Feature Flags SDK. */ + public interface Backend { + /** + * Looks up a setting from the agent configuration. + * + * @param key the setting key, using the {@code dd.} system property notation without prefix. + * @return the setting value, or {@code null} if not set. + */ + @Nullable + String setting(String key); + + /** + * @return whether Remote Configuration is available. + */ + boolean isRemoteConfigAvailable(); + + /** + * Subscribes to the Feature Flags Remote Configuration product. + * + * @param listener the listener receiving raw configuration documents, or {@code null} when the + * configuration is removed. + * @return the handle to close to unsubscribe. + */ + AutoCloseable subscribeRemoteConfig(Consumer listener); + + /** + * @return whether the Datadog Agent event platform proxy is available. + */ + boolean isEventProxyAvailable(); + + /** + * Posts an event payload through the Datadog Agent event platform proxy. + * + * @param route the event platform route. + * @param json the UTF-8 JSON payload. + * @return {@code true} if the payload was delivered, {@code false} if the proxy definitively + * rejected it as unavailable. + * @throws IOException if the payload could not be delivered. + */ + boolean postEvent(String route, byte[] json) throws IOException; + + /** + * Increments a health counter. + * + * @param metric the metric name. + * @param value the increment. + * @param reason the optional {@code reason} tag value. + */ + void count(String metric, long value, @Nullable String reason); + + /** + * Records an evaluation that resolved to a split with a serial id on the local root span. + * + * @param serialId the split serial id. + * @param doLog whether the allocation logs exposures. + * @param targetingKey the optional targeting key. + */ + void enrichSerialId(int serialId, boolean doLog, @Nullable String targetingKey); + + /** + * Records an evaluation that resolved to its runtime default on the local root span. + * + * @param flagKey the flag key. + * @param defaultValue the native default value. + */ + void enrichRuntimeDefault(String flagKey, @Nullable Object defaultValue); } } 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 deleted file mode 100644 index a7518c71743..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/SpanEnrichmentEvent.java +++ /dev/null @@ -1,82 +0,0 @@ -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/exposure/ExposuresRequest.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java deleted file mode 100644 index 3b25028c245..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/exposure/ExposuresRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package datadog.trace.api.featureflag.exposure; - -import java.util.List; -import java.util.Map; - -public class ExposuresRequest { - - public final Map context; - public final List exposures; - - public ExposuresRequest(final Map context, final List exposures) { - this.context = context; - this.exposures = exposures; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java deleted file mode 100644 index 65ffaf80591..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/flagevaluation/FlagEvaluationWriter.java +++ /dev/null @@ -1,46 +0,0 @@ -package datadog.trace.api.featureflag.flagevaluation; - -/** - * Defines an EVP flagevaluation writer responsible for aggregating flag evaluation events and - * flushing them to the EVP proxy. - * - *

    Implementations must use a background thread (serializing handler) for aggregation and - * transport. The enqueue method must be non-blocking and callable from the OpenFeature hook thread - * without backpressure. - */ -public interface FlagEvaluationWriter extends AutoCloseable { - - /** - * Non-blocking enqueue of a flag evaluation event. May silently drop the event if the internal - * bounded queue is full (best-effort, observable via drop counter). - */ - void enqueue(FlagEvalEvent event); - - /** - * Reports whether the internal queue currently has room for another event. Producers should - * consult this before performing expensive context-copy work so a saturated queue is observed as - * an O(1) read rather than a full snapshot followed by a discarded offer. - * - *

    Best-effort: the worker can drain (or a peer producer can fill) between this check and the - * next enqueue, so callers must still tolerate offer failure. - */ - boolean hasCapacityForEnqueue(); - - /** Counts one queue-overflow drop without offering an event. */ - void countPreQueueOverflow(); - - /** - * Counts one evaluation whose context was truncated by copyPrunedContext. The reason string is - * the sorted, comma-separated set of cap names that fired (e.g. - * "max_key_length,max_value_length"). Each unique reason string is counted separately so - * telemetry can distinguish which caps are hot. - */ - void countContextTruncated(String reason); - - /** Starts the background serializing thread. Must be called once after construction. */ - void start(); - - /** Stops the background thread and releases resources. */ - @Override - void close(); -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java deleted file mode 100644 index b572e7e5aff..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Allocation.java +++ /dev/null @@ -1,80 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.time.Instant; -import java.util.Date; -import java.util.List; - -public class Allocation { - public final String key; - public final List rules; - public final Date startAt; - public final Date endAt; - public final List splits; - public final Boolean doLog; - - private final transient Instant preciseStartAt; - private final transient Instant preciseEndAt; - - public Allocation( - final String key, - final List rules, - final Date startAt, - final Date endAt, - final List splits, - final Boolean doLog) { - this( - key, - rules, - startAt, - endAt, - splits, - doLog, - startAt == null ? null : startAt.toInstant(), - endAt == null ? null : endAt.toInstant()); - } - - private Allocation( - final String key, - final List rules, - final Date startAt, - final Date endAt, - final List splits, - final Boolean doLog, - final Instant preciseStartAt, - final Instant preciseEndAt) { - this.key = key; - this.rules = rules; - this.startAt = startAt; - this.endAt = endAt; - this.splits = splits; - this.doLog = doLog; - this.preciseStartAt = preciseStartAt; - this.preciseEndAt = preciseEndAt; - } - - public static Allocation fromInstants( - final String key, - final List rules, - final Instant startAt, - final Instant endAt, - final List splits, - final Boolean doLog) { - return new Allocation( - key, - rules, - startAt == null ? null : Date.from(startAt), - endAt == null ? null : Date.from(endAt), - splits, - doLog, - startAt, - endAt); - } - - public Instant startAtInstant() { - return preciseStartAt != null ? preciseStartAt : startAt == null ? null : startAt.toInstant(); - } - - public Instant endAtInstant() { - return preciseEndAt != null ? preciseEndAt : endAt == null ? null : endAt.toInstant(); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Feature.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Feature.java deleted file mode 100644 index 495b547462f..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Feature.java +++ /dev/null @@ -1,19 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.List; - -/** Key-value data that the UFC attaches to a split, with where SDKs deliver it. */ -public class Feature { - public final String key; - // A String, Double or Boolean; the parser drops features with any other value. - public final Object value; - // Open strings such as HOOK, EXPOSURE or EVALUATION. Never empty: the parser drops a feature - // without destinations. - public final List destinations; - - public Feature(final String key, final Object value, final List destinations) { - this.key = key; - this.value = value; - this.destinations = destinations; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java deleted file mode 100644 index fef1e5f04b4..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/Shard.java +++ /dev/null @@ -1,22 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -import java.util.List; - -/** - * A UFC shard. - * - *

    {@code totalShards} stores the unsigned 32-bit wire value in an {@code int} to preserve this - * bootstrap model's binary compatibility. Consumers must use {@link Integer#toUnsignedLong(int)} - * before comparing it or using it for arithmetic. - */ -public class Shard { - public final String salt; - public final List ranges; - public final int totalShards; - - public Shard(final String salt, final List ranges, final int totalShards) { - this.salt = salt; - this.ranges = ranges; - this.totalShards = totalShards; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java deleted file mode 100644 index 4a8c5078daf..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/ufc/v1/ShardRange.java +++ /dev/null @@ -1,18 +0,0 @@ -package datadog.trace.api.featureflag.ufc.v1; - -/** - * A UFC shard range. - * - *

    {@code start} and {@code end} store unsigned 32-bit wire values in {@code int} fields to - * preserve this bootstrap model's binary compatibility. Consumers must use {@link - * Integer#toUnsignedLong(int)} before comparing them. - */ -public class ShardRange { - public final int start; - public final int end; - - public ShardRange(final int start, final int end) { - this.start = start; - this.end = end; - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java index 887a153f0a1..73bf6c24170 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java @@ -1,115 +1,25 @@ package datadog.trace.api.featureflag; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class FeatureFlaggingGatewayTest { - - private FeatureFlaggingGateway.ConfigListener configListener; - private FeatureFlaggingGateway.ActivationListener activationListener; - private FeatureFlaggingGateway.ExposureListener exposureListener; - private FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener; - private ServerConfiguration firstConfiguration; - private ServerConfiguration secondConfiguration; - private ExposureEvent firstExposure; - private ExposureEvent secondExposure; - - @BeforeEach - void setUp() { - configListener = mock(FeatureFlaggingGateway.ConfigListener.class); - activationListener = mock(FeatureFlaggingGateway.ActivationListener.class); - exposureListener = mock(FeatureFlaggingGateway.ExposureListener.class); - spanEnrichmentListener = mock(FeatureFlaggingGateway.SpanEnrichmentListener.class); - firstConfiguration = mock(ServerConfiguration.class); - secondConfiguration = mock(ServerConfiguration.class); - firstExposure = mock(ExposureEvent.class); - secondExposure = mock(ExposureEvent.class); - } - @AfterEach void tearDown() { - FeatureFlaggingGateway.removeConfigListener(configListener); - FeatureFlaggingGateway.removeActivationListener(activationListener); - FeatureFlaggingGateway.removeExposureListener(exposureListener); - FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @Test - void testProviderActivationListener() { - FeatureFlaggingGateway.addActivationListener(activationListener); - - FeatureFlaggingGateway.activate(); - - verify(activationListener).activate(); - verifyNoMoreInteractions(activationListener); - } - - @Test - void testAttachingAConfigListener() { - clearCurrentServerConfiguration(); - - FeatureFlaggingGateway.addConfigListener(configListener); - FeatureFlaggingGateway.dispatch(firstConfiguration); - - verify(configListener).accept(firstConfiguration); - verifyNoMoreInteractions(configListener); - - FeatureFlaggingGateway.dispatch(secondConfiguration); - - verify(configListener).accept(secondConfiguration); - verifyNoMoreInteractions(configListener); + FeatureFlaggingGateway.register(null); } @Test - void testAttachingAListenerAfterConfigured() { - FeatureFlaggingGateway.dispatch(firstConfiguration); - FeatureFlaggingGateway.addConfigListener(configListener); - - verify(configListener).accept(firstConfiguration); - verifyNoMoreInteractions(configListener); - } - - @Test - void testAttachingAnExposureListener() { - FeatureFlaggingGateway.addExposureListener(exposureListener); - FeatureFlaggingGateway.dispatch(firstExposure); - - verify(exposureListener).accept(firstExposure); - verifyNoMoreInteractions(exposureListener); - - FeatureFlaggingGateway.dispatch(secondExposure); - - verify(exposureListener).accept(secondExposure); - verifyNoMoreInteractions(exposureListener); - } - - @Test - void testAttachingASpanEnrichmentListener() { - final SpanEnrichmentEvent firstEvent = SpanEnrichmentEvent.serialId(42, true, "user-1"); - final SpanEnrichmentEvent secondEvent = SpanEnrichmentEvent.runtimeDefault("flag", "value"); - - FeatureFlaggingGateway.addSpanEnrichmentListener(spanEnrichmentListener); - FeatureFlaggingGateway.dispatch(firstEvent); - - verify(spanEnrichmentListener).accept(firstEvent); - verifyNoMoreInteractions(spanEnrichmentListener); - - FeatureFlaggingGateway.dispatch(secondEvent); - - verify(spanEnrichmentListener).accept(secondEvent); - verifyNoMoreInteractions(spanEnrichmentListener); - } - - private static void clearCurrentServerConfiguration() { - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + void registersAndUnregistersTheBackend() { + assertNull(FeatureFlaggingGateway.backend()); + final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); + FeatureFlaggingGateway.register(backend); + assertSame(backend, FeatureFlaggingGateway.backend()); + FeatureFlaggingGateway.register(null); + assertNull(FeatureFlaggingGateway.backend()); } } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java deleted file mode 100644 index 55e7a8943ac..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/SpanEnrichmentEventTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package datadog.trace.api.featureflag; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import org.junit.jupiter.api.Test; - -class SpanEnrichmentEventTest { - - @Test - void serialIdEventCarriesItsFields() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(42, true, "user-1"); - - assertTrue(event.hasSerialId()); - assertEquals(42, event.serialId()); - assertTrue(event.doLog()); - assertEquals("user-1", event.targetingKey()); - assertNull(event.flagKey()); - assertNull(event.defaultValue()); - } - - @Test - void serialIdEventWithoutDoLogOrTargetingKey() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.serialId(7, false, null); - - assertTrue(event.hasSerialId()); - assertEquals(7, event.serialId()); - assertFalse(event.doLog()); - assertNull(event.targetingKey()); - } - - @Test - void runtimeDefaultEventCarriesItsFields() { - final Object value = Collections.singletonMap("k", "v"); - final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", value); - - assertFalse(event.hasSerialId()); - assertEquals("flag", event.flagKey()); - assertEquals(value, event.defaultValue()); - assertEquals(0, event.serialId()); - assertFalse(event.doLog()); - assertNull(event.targetingKey()); - } - - @Test - void runtimeDefaultEventAllowsNullValue() { - final SpanEnrichmentEvent event = SpanEnrichmentEvent.runtimeDefault("flag", null); - - assertFalse(event.hasSerialId()); - assertEquals("flag", event.flagKey()); - assertNull(event.defaultValue()); - } -} diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java deleted file mode 100644 index cc614150178..00000000000 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/flagevaluation/FlagEvalEventTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package datadog.trace.api.featureflag.flagevaluation; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Collections; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class FlagEvalEventTest { - - @Test - void storesFieldsWithContextAttributes() { - final Map attrs = Collections.singletonMap("tier", "gold"); - - final FlagEvalEvent event = - new FlagEvalEvent("my-flag", "on", "allocation-1", "target-1", 123L, attrs); - - assertEquals("my-flag", event.flagKey); - assertEquals("on", event.variant); - assertEquals("allocation-1", event.allocationKey); - assertEquals("target-1", event.targetingKey); - assertNull(event.errorMessage); - assertEquals(123L, event.evalTimeMs); - assertSame(attrs, event.attrs); - } - - @Test - void storesErrorMessageAndDefaultsNullContextAttributes() { - final Map attrs = null; - final FlagEvalEvent event = - new FlagEvalEvent("my-flag", null, null, null, "type mismatch", 456L, attrs); - - assertEquals("type mismatch", event.errorMessage); - assertTrue(event.attrs.isEmpty()); - } - - @Test - void observeFullEvaluationDataDefaultsToFalseOnConvenienceConstructors() { - final Map attrs = Collections.emptyMap(); - assertFalse(new FlagEvalEvent("f", "on", "a", "t", 1L, attrs).observeFullEvaluationData); - assertFalse(new FlagEvalEvent("f", "on", "a", "t", null, 1L, attrs).observeFullEvaluationData); - } - - @Test - void storesExplicitObserveFullEvaluationData() { - final Map attrs = Collections.emptyMap(); - assertTrue( - new FlagEvalEvent("f", "on", "a", "t", null, 1L, true, attrs).observeFullEvaluationData); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 4afd818b839..5539b495e70 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -2,29 +2,17 @@ plugins { `java-library` id("dd-trace-java.version-file") id("dd-trace-java.module.product-library") - id("me.champeau.jmh") } -description = "Feature flagging remote config and exposure handling" - -extra["excludedClassesCoverage"] = listOf( - // POJOs - "com.datadog.featureflag.ExposureCache.Key", - "com.datadog.featureflag.ExposureCache.Value" -) +description = "Feature flagging agent backend: remote config, event proxy and span enrichment" dependencies { api(libs.slf4j) - api(libs.moshi) - api(libs.jctools) api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) compileOnly(project(":products:feature-flagging:feature-flagging-config")) - implementation(project(":utils:logging-utils")) - api(project(":utils:queue-utils")) - compileOnly(project(":dd-trace-core")) // shading does not work with this one // Platform JSON writer for the ffe_* tag values. compileOnly(project(":components:json")) @@ -34,14 +22,3 @@ dependencies { testImplementation(project(":utils:test-utils")) testImplementation(project(":dd-java-agent:testing")) } - -jmh { - jmhVersion = libs.versions.jmh.get() - duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE - if (project.hasProperty("jmhIncludes")) { - includes = listOf(project.property("jmhIncludes").toString()) - } - if (project.hasProperty("jmhProf")) { - profilers = listOf(project.property("jmhProf").toString()) - } -} diff --git a/products/feature-flagging/feature-flagging-lib/gradle.lockfile b/products/feature-flagging/feature-flagging-lib/gradle.lockfile index 580a1c9f00c..79a810ea0c1 100644 --- a/products/feature-flagging/feature-flagging-lib/gradle.lockfile +++ b/products/feature-flagging/feature-flagging-lib/gradle.lockfile @@ -62,7 +62,7 @@ org.apache.commons:commons-math3:3.6.1=jmh,jmhCompileClasspath,jmhRuntimeClasspa org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +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 @@ -81,8 +81,8 @@ org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt org.jacoco:org.jacoco.core:0.8.15=jacocoAnt org.jacoco:org.jacoco.report:0.8.15=jacocoAnt -org.jctools:jctools-core-jdk11:4.0.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jctools:jctools-core:4.0.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jctools:jctools-core-jdk11:4.0.6=jmhCompileClasspath,jmhRuntimeClasspath,testRuntimeClasspath +org.jctools:jctools-core:4.0.6=jmhCompileClasspath,jmhRuntimeClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.14.1=jmhRuntimeClasspath,testRuntimeClasspath diff --git a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java deleted file mode 100644 index bbec5d65229..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationEnqueueContentionBenchmark.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.communication.BackendApiFactory; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.intake.Intake; -import de.thetaphi.forbiddenapis.SuppressForbidden; -import java.util.HashMap; -import java.util.Map; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Group; -import org.openjdk.jmh.annotations.GroupThreads; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; -import org.openjdk.jmh.infra.Blackhole; - -/** - * Producer-contention benchmark for {@link FlagEvaluationWriterImpl#enqueue}. - * - *

    Every flag evaluation in every application thread calls {@code enqueue}, so producer-side - * scaling is the property that matters. {@link FlagEvaluationHotPathBenchmark#writerEnqueue} - * measures the single-threaded cost and cannot see contention; this benchmark measures how enqueue - * behaves as producer count grows. - * - *

    Why groups rather than {@code @Threads}: the hand-off queue is a JCTools - * {@code mpscBlockingConsumerArrayQueue} - multi-producer, single-consumer. Calling {@code - * poll()} from several threads breaks that contract, so producer threads cannot drain their own - * events. Each group therefore pairs N producers with exactly one consumer. - * - *

    Why the consumer drains in batches: with more producers than consumers the - * queue would otherwise saturate, after which {@code offer} fast-fails and the measurement turns - * into {@code AtomicLong} contention on the overflow counter instead of the enqueue path. The - * consumer polls up to {@link #DRAIN_BATCH} events per invocation to keep the queue off its - * capacity limit. Overflow is reported at the end of each iteration so a saturated - and therefore - * invalid - run is visible rather than silent. - * - *

    Run: {@code ./gradlew :products:feature-flagging:feature-flagging-lib:jmh - * -PjmhIncludes=FlagEvaluationEnqueueContentionBenchmark}. - */ -@State(Scope.Benchmark) -@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(NANOSECONDS) -@Fork(value = 1) -public class FlagEvaluationEnqueueContentionBenchmark { - - /** Events the single consumer drains per invocation, so it can keep up with N producers. */ - static final int DRAIN_BATCH = 32; - - private static final int NUM_FLAGS = 100; - private static final int NUM_USERS = 50; - private static final int NUM_FIELDS = 10; - - private Map attrs; - private String[] flagKeys; - private String[] targetingKeys; - private FlagEvaluationWriterImpl writer; - - /** Per-producer cursor: a shared counter would add its own cache-line contention. */ - @State(Scope.Thread) - public static class ProducerCursor { - int cursor; - } - - @Setup(Level.Iteration) - public void setUp() { - // enqueue() no-ops unless the gateway gate is on; set it explicitly so the benchmark is not - // silently measuring an early return. - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - - attrs = new HashMap<>(); - for (int i = 0; i < NUM_FIELDS; i++) { - attrs.put("field" + i, "value"); - } - flagKeys = keys("bench-flag-", NUM_FLAGS); - targetingKeys = keys("bench-user-", NUM_USERS); - - final Config config = Config.get(); - final BackendApiFactory factory = new BackendApiFactory(config, null); - // Capacity well above what the batch-draining consumer should ever let build up. - writer = - new FlagEvaluationWriterImpl( - 1 << 20, - Long.MAX_VALUE, - NANOSECONDS, - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), - config); - } - - /** - * Reports queue overflow so a saturated run - where the consumer failed to keep up and the - * numbers no longer describe the enqueue path - is visible in the benchmark output. - */ - @org.openjdk.jmh.annotations.TearDown(Level.Iteration) - @SuppressForbidden - public void reportOverflow() { - final long dropped = writer.droppedQueueOverflow(); - if (dropped > 0) { - System.out.println( - "\nWARNING: queue overflowed " - + dropped - + " times - consumer could not keep up, enqueue timings for this iteration are" - + " measuring overflow accounting, not the enqueue path."); - } - } - - // ---- 1 producer: uncontended baseline ---- - - @Benchmark - @Group("producers1") - @GroupThreads(1) - public void enqueue1(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers1") - @GroupThreads(1) - public void drain1(final Blackhole blackhole) { - drain(blackhole); - } - - // ---- 4 producers ---- - - @Benchmark - @Group("producers4") - @GroupThreads(4) - public void enqueue4(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers4") - @GroupThreads(1) - public void drain4(final Blackhole blackhole) { - drain(blackhole); - } - - // ---- 16 producers ---- - - @Benchmark - @Group("producers16") - @GroupThreads(16) - public void enqueue16(final ProducerCursor c) { - writer.enqueue(nextEvent(c)); - } - - @Benchmark - @Group("producers16") - @GroupThreads(1) - public void drain16(final Blackhole blackhole) { - drain(blackhole); - } - - private void drain(final Blackhole blackhole) { - for (int i = 0; i < DRAIN_BATCH; i++) { - final FlagEvalEvent event = writer.pollQueuedEventForTest(); - if (event == null) { - break; - } - blackhole.consume(event); - } - } - - private FlagEvalEvent nextEvent(final ProducerCursor c) { - final int i = c.cursor++; - return new FlagEvalEvent( - flagKeys[Math.floorMod(i, flagKeys.length)], - "variant-" + Math.floorMod(i, 4), - "alloc-" + Math.floorMod(i, flagKeys.length), - targetingKeys[Math.floorMod(i, targetingKeys.length)], - null, - 1_700_000_000_000L + i, - attrs); - } - - private static String[] keys(final String prefix, final int count) { - final String[] out = new String[count]; - for (int i = 0; i < count; i++) { - out[i] = prefix + i; - } - return out; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java b/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java deleted file mode 100644 index fbd467b88d2..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/jmh/java/com/datadog/featureflag/FlagEvaluationHotPathBenchmark.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; - -import datadog.communication.BackendApiFactory; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.intake.Intake; -import java.util.HashMap; -import java.util.Map; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; -import org.openjdk.jmh.infra.Blackhole; - -/** - * Writer-side benchmark for EVP {@code flagevaluation} recording: the bounded-queue enqueue and the - * worker-thread aggregation of already-captured events. - * - *

    Scope limitation: this benchmark starts from a pre-built FlagEvalEvent - * holding a ready-made flat attribute map. It therefore does NOT cover the OpenFeature hook's own - * inline capture cost - in particular the bounded evaluation-context copy that - * FlagEvalLoggingHook.finallyAfter performs synchronously on the evaluation thread under - * consent-on. That cost is measured separately by FlagEvalHookHotPathBenchmark in - * feature-flagging-api, where the hook and the OpenFeature context types live. Do not read - * writerEnqueue as the total per-evaluation cost paid by the caller. - * - *

    Events are built with observeFullEvaluationData=true so the aggregator actually canonicalizes - * the context. Under the consent-off default it drops attrs and skips canonicalization, which makes - * every field-count profile measure the same scalar-only work. - * - *

    Run: {@code ./gradlew :products:feature-flagging:feature-flagging-lib:jmh - * -PjmhIncludes=FlagEvaluationHotPathBenchmark}. - */ -@State(Scope.Benchmark) -@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(NANOSECONDS) -@Fork(value = 1) -public class FlagEvaluationHotPathBenchmark { - - @Param({ - "typical/100flags_50users_10fields", - "stress/10flags_1000users_250fields", - "scale/2500flags_500users_20fields" - }) - public String profile; - - private Map attrs; - private String[] flagKeys; - private String[] targetingKeys; - private int cursor; - private FlagEvaluationWriterImpl writer; - private FlagEvaluationWriterImpl.SerializingHandlerForTest handler; - - @Setup(Level.Iteration) - public void setUp() { - final Profile p = Profile.fromName(profile); - - attrs = new HashMap<>(); - for (int i = 0; i < p.numFields; i++) { - attrs.put("field" + i, "value"); - } - flagKeys = keys("bench-flag-", p.numFlags); - targetingKeys = keys("bench-user-", p.numUsers); - cursor = 0; - - final Config config = Config.get(); - final BackendApiFactory factory = new BackendApiFactory(config, null); - final Map ddContext = new HashMap<>(); - ddContext.put("service", "bench-service"); - handler = - FlagEvaluationWriterImpl.createHandlerForTest( - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), ddContext); - - // Capacity large enough that the benchmark never overflows within a measurement window. - writer = - new FlagEvaluationWriterImpl( - 1 << 20, - Long.MAX_VALUE, - NANOSECONDS, - () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false), - config); - } - - /** - * Queue-mechanics cost only: event allocation plus the non-blocking bounded-queue offer. Excludes - * the hook's inline context snapshot - see the class javadoc. - */ - @Benchmark - public void writerEnqueue(final Blackhole blackhole) { - final FlagEvalEvent event = nextEvent(); - writer.enqueue(event); - blackhole.consume(writer.pollQueuedEventForTest()); - blackhole.consume(event); - } - - /** Worker-thread cost: materialize context, prune, canonicalize, and aggregate. */ - @Benchmark - public void workerAggregate(final Blackhole blackhole) { - final FlagEvalEvent event = nextEvent(); - handler.aggregateEvent(event); - if ((cursor % 10_000) == 0) { - handler.clearAggregationForTest(); - } - blackhole.consume(handler.fullTierSizeForTest()); - } - - private FlagEvalEvent nextEvent() { - final int i = cursor++; - // observeFullEvaluationData=true is required for this benchmark to mean anything: under the - // consent-off default the aggregator drops attrs and skips canonicalContextKey entirely, so - // every field-count profile would collapse to the same scalar-only cost. - return new FlagEvalEvent( - flagKeys[Math.floorMod(i, flagKeys.length)], - "variant-" + Math.floorMod(i, 4), - "alloc-" + Math.floorMod(i, flagKeys.length), - targetingKeys[Math.floorMod(i, targetingKeys.length)], - null, - 1_700_000_000_000L + i, - true, - attrs); - } - - private static String[] keys(final String prefix, final int count) { - final String[] out = new String[count]; - for (int i = 0; i < count; i++) { - out[i] = prefix + i; - } - return out; - } - - private static final class Profile { - private final int numFlags; - private final int numUsers; - private final int numFields; - - private Profile(final int numFlags, final int numUsers, final int numFields) { - this.numFlags = numFlags; - this.numUsers = numUsers; - this.numFields = numFields; - } - - private static Profile fromName(final String name) { - if ("typical/100flags_50users_10fields".equals(name)) { - return new Profile(100, 50, 10); - } - if ("stress/10flags_1000users_250fields".equals(name)) { - return new Profile(10, 1_000, 250); - } - if ("scale/2500flags_500users_20fields".equals(name)) { - return new Profile(2_500, 500, 20); - } - throw new IllegalArgumentException("unknown benchmark profile: " + name); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java deleted file mode 100644 index f4fc35cce30..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java +++ /dev/null @@ -1,489 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.communication.http.OkHttpUtils.prepareRequest; -import static datadog.communication.http.OkHttpUtils.sendWithRetries; -import static datadog.trace.util.AgentThreadFactory.AgentThread.FEATURE_FLAG_CONFIGURATION_POLLER; -import static datadog.trace.util.Strings.isBlank; - -import datadog.communication.http.HttpRetryPolicy; -import datadog.communication.http.OkHttpUtils; -import datadog.logging.RatelimitedLogger; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.util.AgentThreadFactory; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.DoubleSupplier; -import javax.annotation.Nullable; -import okhttp3.Call; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -final class AgentlessConfigurationSource implements ConfigurationSourceService { - private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessConfigurationSource.class); - - private static final String DATADOG_UFC_RULES_BASED_SERVER_PATH = - "/api/v2/feature-flagging/config/rules-based/server"; - private static final int MAX_ATTEMPTS = 3; - private static final int MINUTES_BETWEEN_WARNINGS = 5; - private static final long FIRST_RETRY_MIN_MILLIS = 2_000; - private static final long FIRST_RETRY_MAX_MILLIS = 10_000; - private static final long SECOND_RETRY_MIN_MILLIS = 5_000; - private static final long SECOND_RETRY_MAX_MILLIS = 30_000; - private static final double RETRY_JITTER = 0.2; - - private final HttpUrl endpoint; - private final Config config; - private final long pollIntervalMillis; - private final UfcHttpClient client; - private final ScheduledExecutorService executor; - private final RatelimitedLogger ratelimitedLogger; - private final Object lifecycleLock = new Object(); - private final AtomicBoolean polling = new AtomicBoolean(); - private final AtomicReference pollingThread = new AtomicReference<>(); - private volatile boolean closed; - private volatile boolean started; - private volatile ScheduledFuture scheduledPoll; - private volatile String etag; - - AgentlessConfigurationSource(final Config config) { - this(config, endpoint(config)); - } - - private AgentlessConfigurationSource(final Config config, final HttpUrl endpoint) { - this( - endpoint, - config, - millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), - new OkHttpUfcHttpClient( - OkHttpUtils.buildHttpClient( - endpoint, - millis(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds())), - millis(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()), - TimeUnit.MILLISECONDS::sleep, - () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)), - Executors.newSingleThreadScheduledExecutor( - new AgentThreadFactory(FEATURE_FLAG_CONFIGURATION_POLLER)), - new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); - } - - AgentlessConfigurationSource( - final HttpUrl endpoint, - final Config config, - final long pollIntervalMillis, - final UfcHttpClient client, - final ScheduledExecutorService executor) { - this( - endpoint, - config, - pollIntervalMillis, - client, - executor, - new RatelimitedLogger(LOGGER, MINUTES_BETWEEN_WARNINGS, TimeUnit.MINUTES)); - } - - AgentlessConfigurationSource( - final HttpUrl endpoint, - final Config config, - final long pollIntervalMillis, - final UfcHttpClient client, - final ScheduledExecutorService executor, - final RatelimitedLogger ratelimitedLogger) { - this.endpoint = endpoint; - this.config = config; - this.pollIntervalMillis = pollIntervalMillis; - this.client = client; - this.executor = executor; - this.ratelimitedLogger = ratelimitedLogger; - } - - @Override - public void init() { - synchronized (lifecycleLock) { - if (closed || started) { - return; - } - started = true; - } - - // Complete the first poll cycle on the activation thread. This lets OpenFeature provider - // initialization observe a successful retry before it checks whether configuration is ready. - // No request occurs before application code activates the provider. - pollOnceSafely(); - - synchronized (lifecycleLock) { - if (!closed) { - scheduledPoll = - executor.scheduleWithFixedDelay( - this::pollOnceSafely, - pollIntervalMillis, - pollIntervalMillis, - TimeUnit.MILLISECONDS); - } - } - } - - boolean pollOnce() { - if (closed || !polling.compareAndSet(false, true)) { - return false; - } - pollingThread.set(Thread.currentThread()); - try { - return fetchAndApply(); - } finally { - pollingThread.compareAndSet(Thread.currentThread(), null); - polling.set(false); - } - } - - @Override - public void close() { - final ScheduledFuture poll; - synchronized (lifecycleLock) { - if (closed) { - return; - } - closed = true; - started = false; - poll = scheduledPoll; - scheduledPoll = null; - } - if (poll != null) { - poll.cancel(true); - } - client.cancel(); - final Thread activePollingThread = pollingThread.get(); - if (activePollingThread != null) { - activePollingThread.interrupt(); - } - executor.shutdownNow(); - } - - private void pollOnceSafely() { - try { - pollOnce(); - } catch (final RuntimeException e) { - LOGGER.debug("Unexpected error while polling Feature Flagging HTTP configuration source", e); - } - } - - private boolean fetchAndApply() { - try { - final UfcHttpResponse response = client.fetch(endpoint, config, etag); - if (closed) { - return false; - } - if (isRetryableStatus(response.status)) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", - MAX_ATTEMPTS, - response.status); - return false; - } - synchronized (lifecycleLock) { - return !closed && apply(response); - } - } catch (final IOException e) { - if (!closed) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint request failed after {} attempts", - MAX_ATTEMPTS, - e); - } - return false; - } - } - - private boolean apply(final UfcHttpResponse response) { - if (response.status == HttpURLConnection.HTTP_NOT_MODIFIED) { - return true; - } - if (response.status == HttpURLConnection.HTTP_UNAUTHORIZED - || response.status == HttpURLConnection.HTTP_FORBIDDEN) { - ratelimitedLogger.warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - response.status); - return false; - } - if (response.status != HttpURLConnection.HTTP_OK || response.body == null) { - return false; - } - final ServerConfiguration configuration; - try { - configuration = JsonApiUfcResponseParser.INSTANCE.parse(response.body); - } catch (final IOException | RuntimeException e) { - LOGGER.debug("Feature Flagging HTTP configuration source returned malformed UFC payload", e); - return false; - } - if (configuration == null) { - return false; - } - FeatureFlaggingGateway.dispatch(configuration); - updateEtag(response.etag); - return true; - } - - private static boolean isRetryableStatus(final int status) { - return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT - || status == 429 - || (status >= 500 && status <= 599); - } - - private void updateEtag(final String nextEtag) { - etag = isBlank(nextEtag) ? null : nextEtag; - } - - static HttpUrl endpoint(final Config config) { - final String configuredBaseUrl = config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl(); - return configuredBaseUrl == null - ? datadogApiServerDistributionEndpoint(config) - : endpointFromConfiguredBaseUrl(configuredBaseUrl); - } - - private static HttpUrl endpointFromConfiguredBaseUrl(final String configuredBaseUrl) { - final HttpUrl parsed = HttpUrl.parse(configuredBaseUrl.trim()); - if (parsed == null) { - throw new IllegalArgumentException( - "Invalid Feature Flagging HTTP configuration source URL: " + configuredBaseUrl); - } - if ("/".equals(parsed.encodedPath()) || parsed.encodedPath().isEmpty()) { - return parsed - .newBuilder() - .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)) - .build(); - } - return parsed; - } - - private static HttpUrl datadogApiServerDistributionEndpoint(final Config config) { - final HttpUrl.Builder endpoint = - new HttpUrl.Builder() - .scheme("https") - .host("ufc-server.ff-cdn." + config.getSite()) - .addPathSegments(DATADOG_UFC_RULES_BASED_SERVER_PATH.substring(1)); - final String env = config.getEnv(); - if (env != null && !env.isEmpty()) { - endpoint.addQueryParameter("dd_env", env); - } - return endpoint.build(); - } - - static long millis(final int seconds) { - return TimeUnit.SECONDS.toMillis(seconds); - } - - static long retryDelayMillis( - final long pollIntervalMillis, final int attempt, final double jitter) { - final long baseDelay; - if (attempt == 1) { - baseDelay = clamp(pollIntervalMillis / 6, FIRST_RETRY_MIN_MILLIS, FIRST_RETRY_MAX_MILLIS); - } else if (attempt == 2) { - baseDelay = clamp(pollIntervalMillis / 3, SECOND_RETRY_MIN_MILLIS, SECOND_RETRY_MAX_MILLIS); - } else { - throw new IllegalArgumentException("Unsupported Feature Flagging retry attempt: " + attempt); - } - return Math.max(1, Math.round(baseDelay * jitter)); - } - - private static long clamp(final long value, final long minimum, final long maximum) { - return Math.max(minimum, Math.min(maximum, value)); - } - - interface UfcHttpClient { - UfcHttpResponse fetch(HttpUrl endpoint, Config config, String etag) throws IOException; - - void cancel(); - } - - interface RetrySleeper { - void sleep(long delayMillis) throws InterruptedException; - } - - static final class UfcHttpResponse { - final int status; - @Nullable final String etag; - @Nullable final byte[] body; - - UfcHttpResponse(final int status, @Nullable final String etag, @Nullable final byte[] body) { - this.status = status; - this.etag = etag; - this.body = body; - } - } - - static final class OkHttpUfcHttpClient implements UfcHttpClient { - private final OkHttpClient httpClient; - private final long pollIntervalMillis; - private final RetrySleeper retrySleeper; - private final DoubleSupplier jitter; - private final AtomicBoolean fetching = new AtomicBoolean(); - private final AtomicBoolean cancelled = new AtomicBoolean(); - private final AtomicReference activeCall = new AtomicReference<>(); - - OkHttpUfcHttpClient(final OkHttpClient httpClient) { - this( - httpClient, - TimeUnit.SECONDS.toMillis(30), - TimeUnit.MILLISECONDS::sleep, - () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); - } - - OkHttpUfcHttpClient( - final OkHttpClient httpClient, - final long pollIntervalMillis, - final RetrySleeper retrySleeper, - final DoubleSupplier jitter) { - this.httpClient = httpClient; - this.pollIntervalMillis = pollIntervalMillis; - this.retrySleeper = retrySleeper; - this.jitter = jitter; - } - - @Override - public UfcHttpResponse fetch(final HttpUrl endpoint, final Config config, final String etag) - throws IOException { - final Map headers = new HashMap<>(); - if (etag != null) { - headers.put("If-None-Match", etag); - } - // Leave Accept-Encoding unset so OkHttp negotiates gzip and transparently decompresses it. - final Request request = - prepareRequest(endpoint, headers, config, isDatadogManagedEndpoint(endpoint, config)) - .get() - .build(); - if (!fetching.compareAndSet(false, true)) { - throw new IllegalStateException("Feature Flagging HTTP request already in flight"); - } - if (cancelled.get()) { - fetching.set(false); - throw new InterruptedIOException("Feature Flagging HTTP client is closed"); - } - try { - final HttpRetryPolicy.Factory retryPolicyFactory = - new HttpRetryPolicy.Factory(0, 0, 0) { - @Override - public HttpRetryPolicy create() { - return new AgentlessRetryPolicy( - cancelled, pollIntervalMillis, retrySleeper, jitter); - } - }; - final Call.Factory callFactory = - retryRequest -> { - final Call call = httpClient.newCall(retryRequest); - activeCall.set(call); - if (cancelled.get()) { - call.cancel(); - } - return call; - }; - return sendWithRetries( - callFactory, - retryPolicyFactory, - request, - response -> { - final int status = response.code(); - final String responseEtag = response.header("ETag"); - try (ResponseBody responseBody = response.body()) { - final byte[] body = responseBody != null ? responseBody.bytes() : null; - return new UfcHttpResponse(status, responseEtag, body); - } - }); - } finally { - activeCall.set(null); - fetching.set(false); - } - } - - @Override - public void cancel() { - cancelled.set(true); - final Call call = activeCall.get(); - if (call != null) { - call.cancel(); - } - } - - private static boolean isDatadogManagedEndpoint(final HttpUrl endpoint, final Config config) { - return config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl() == null - && endpoint.isHttps() - && endpoint.host().equalsIgnoreCase("ufc-server.ff-cdn." + config.getSite()); - } - } - - @SuppressFBWarnings( - value = "AT_NONATOMIC_OPERATIONS_ON_SHARED_VARIABLE", - justification = - "Each retry policy belongs to one synchronous HTTP request and is confined to one thread") - static final class AgentlessRetryPolicy extends HttpRetryPolicy { - private final AtomicBoolean cancelled; - private final long pollIntervalMillis; - private final RetrySleeper retrySleeper; - private final DoubleSupplier jitter; - private int retriesLeft = MAX_ATTEMPTS - 1; - private int retryAttempt; - - AgentlessRetryPolicy( - final AtomicBoolean cancelled, - final long pollIntervalMillis, - final RetrySleeper retrySleeper, - final DoubleSupplier jitter) { - super(0, 0, 0, false); - this.cancelled = cancelled; - this.pollIntervalMillis = pollIntervalMillis; - this.retrySleeper = retrySleeper; - this.jitter = jitter; - } - - @Override - public boolean shouldRetry(final Exception exception) { - return exception instanceof IOException - && !Thread.currentThread().isInterrupted() - && reserveRetry(); - } - - @Override - public boolean shouldRetry(@Nullable final Response response) { - return response != null && isRetryableStatus(response.code()) && reserveRetry(); - } - - private boolean reserveRetry() { - if (cancelled.get() || retriesLeft == 0) { - return false; - } - retriesLeft--; - retryAttempt++; - return true; - } - - @Override - public void backoff() throws IOException { - if (cancelled.get()) { - throw new InterruptedIOException("Feature Flagging HTTP client is closed"); - } - try { - retrySleeper.sleep( - retryDelayMillis(pollIntervalMillis, retryAttempt, jitter.getAsDouble())); - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new InterruptedIOException("Feature Flagging retry interrupted"); - } - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java deleted file mode 100644 index 769ebfd1dd1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApi.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.datadog.featureflag; - -import datadog.communication.BackendApi; -import datadog.communication.HttpResponseException; -import datadog.communication.http.OkHttpUtils; -import datadog.communication.util.IOThrowingFunction; -import java.io.IOException; -import java.io.InputStream; -import java.net.ConnectException; -import java.util.function.Supplier; -import javax.annotation.Nullable; -import okhttp3.RequestBody; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Sends Feature Flag events through a local EVP proxy, with a safe direct intake fallback. */ -final class AgentlessFeatureFlagBackendApi implements BackendApi { - - private static final Logger LOGGER = - LoggerFactory.getLogger(AgentlessFeatureFlagBackendApi.class); - - private final BackendApi proxyApi; - private final Supplier directApiSupplier; - private final String eventType; - private volatile BackendApi activeApi; - private volatile boolean directApiCreationAttempted; - - AgentlessFeatureFlagBackendApi( - final BackendApi proxyApi, - final Supplier directApiSupplier, - final String eventType) { - this.proxyApi = proxyApi; - this.directApiSupplier = directApiSupplier; - this.eventType = eventType; - this.activeApi = proxyApi; - } - - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - final BackendApi selectedApi = activeApi; - try { - return selectedApi.post( - uri, requestBody, responseParser, requestListener, requestCompression); - } catch (final IOException exception) { - if (selectedApi != proxyApi || !isDefinitiveRejection(exception)) { - throw exception; - } - - final BackendApi directApi = getOrCreateDirectApi(); - if (directApi == null) { - throw exception; - } - return directApi.post(uri, requestBody, responseParser, requestListener, requestCompression); - } - } - - @Nullable - private BackendApi getOrCreateDirectApi() { - final BackendApi selectedApi = activeApi; - if (selectedApi != proxyApi) { - return selectedApi; - } - - synchronized (this) { - final BackendApi currentApi = activeApi; - if (currentApi != proxyApi) { - return currentApi; - } - if (directApiCreationAttempted) { - return null; - } - - final BackendApi directApi = directApiSupplier.get(); - if (directApi != null) { - LOGGER.debug( - "Switching Feature Flagging {} delivery from the local EVP proxy to direct intake", - eventType); - activeApi = directApi; - } - directApiCreationAttempted = true; - return directApi; - } - } - - private static boolean isDefinitiveRejection(final IOException exception) { - if (exception instanceof ConnectException) { - return true; - } - if (exception instanceof HttpResponseException) { - final int statusCode = ((HttpResponseException) exception).getStatusCode(); - return statusCode == 403 || statusCode == 404 || statusCode == 405; - } - return false; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java deleted file mode 100644 index 83495545717..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ConfigurationSourceService.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.datadog.featureflag; - -import java.io.Closeable; - -public interface ConfigurationSourceService extends Closeable { - - void init(); - - void close(); -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java new file mode 100644 index 00000000000..66a168474d8 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java @@ -0,0 +1,103 @@ +package com.datadog.featureflag; + +import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; +import datadog.communication.HttpResponseException; +import datadog.communication.http.HttpRetryPolicy; +import datadog.trace.api.intake.Intake; +import java.io.IOException; +import java.net.ConnectException; +import javax.annotation.Nullable; +import okhttp3.MediaType; +import okhttp3.RequestBody; + +/** Posts Feature Flags events through the Datadog Agent event platform proxy. */ +class EventProxy { + private static final MediaType JSON = MediaType.parse("application/json"); + static final String EXPOSURES_ROUTE = "exposures"; + + private final BackendApiFactory backendApiFactory; + private final boolean directFallbackAvailable; + private volatile BackendApi exposures; + private volatile BackendApi flagEvaluations; + private volatile boolean resolved; + + /** + * @param backendApiFactory the factory of event platform proxy clients. + * @param directFallbackAvailable whether the SDK falls back to direct intake when the proxy is + * unavailable, in which case the proxy must not retry what the intake would accept. + */ + EventProxy(final BackendApiFactory backendApiFactory, final boolean directFallbackAvailable) { + this.backendApiFactory = backendApiFactory; + this.directFallbackAvailable = directFallbackAvailable; + } + + /** + * @return whether the Datadog Agent supports the event platform proxy. + */ + boolean isAvailable() { + resolve(); + return this.exposures != null; + } + + /** + * Posts a payload. + * + * @param route the event platform route. + * @param json the UTF-8 JSON payload. + * @return {@code true} if delivered, {@code false} if the proxy is definitively unavailable. + * @throws IOException if the payload could not be delivered. + */ + boolean post(final String route, final byte[] json) throws IOException { + resolve(); + final BackendApi api = EXPOSURES_ROUTE.equals(route) ? this.exposures : this.flagEvaluations; + if (api == null) { + return false; + } + try { + api.post(route, RequestBody.create(JSON, json), stream -> null, null, false); + return true; + } catch (final IOException e) { + if (isDefinitiveRejection(e)) { + return false; + } + throw e; + } + } + + private void resolve() { + if (this.resolved) { + return; + } + synchronized (this) { + if (this.resolved) { + return; + } + this.exposures = create(FeatureFlagEventType.EXPOSURE); + this.flagEvaluations = create(FeatureFlagEventType.FLAG_EVALUATION); + this.resolved = true; + } + } + + @Nullable + private BackendApi create(final FeatureFlagEventType eventType) { + return this.directFallbackAvailable + ? this.backendApiFactory.createEvpProxyApi( + Intake.EVENT_PLATFORM, + eventType.responseCompressionEnabled(), + HttpRetryPolicy.Factory.NEVER_RETRY) + : this.backendApiFactory.createEvpProxyApi( + Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); + } + + static boolean isDefinitiveRejection(final IOException exception) { + if (exception instanceof ConnectException) { + return true; + } + if (exception instanceof HttpResponseException) { + final int statusCode = ((HttpResponseException) exception).getStatusCode(); + return statusCode == 403 || statusCode == 404 || statusCode == 405; + } + return false; + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java deleted file mode 100644 index 4c9fa80ea0d..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriter.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway.ExposureListener; - -/** - * Defines an exposure writer responsible for sending exposure events to the EVP proxy. - * Implementations should use a background thread to perform these operations asynchronously. - */ -public interface ExposureWriter extends AutoCloseable, ExposureListener { - - void init(); - - void close(); -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java deleted file mode 100644 index 451b903ecc7..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagBackendApiFactory.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Selects the transport for Feature Flagging events. */ -final class FeatureFlagBackendApiFactory { - - private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagBackendApiFactory.class); - - private final Config config; - private final BackendApiFactory backendApiFactory; - private final FeatureFlagEventType eventType; - - FeatureFlagBackendApiFactory( - final Config config, - final SharedCommunicationObjects sharedCommunicationObjects, - final FeatureFlagEventType eventType) { - this(config, new BackendApiFactory(config, sharedCommunicationObjects), eventType); - } - - FeatureFlagBackendApiFactory( - final Config config, - final BackendApiFactory backendApiFactory, - final FeatureFlagEventType eventType) { - this.config = config; - this.backendApiFactory = backendApiFactory; - this.eventType = eventType; - } - - @Nullable - BackendApi create() { - final boolean directFallbackAvailable = - CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) - && hasDirectCredentials(); - final BackendApi proxyApi = - directFallbackAvailable - ? backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, - eventType.responseCompressionEnabled(), - HttpRetryPolicy.Factory.NEVER_RETRY) - : backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); - if (!CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - if (proxyApi == null) { - LOGGER.warn( - "Feature Flagging {} delivery is disabled because the local Agent does not support the EVP proxy", - eventType.logName()); - } - return proxyApi; - } - - if (proxyApi != null) { - if (directFallbackAvailable) { - return new AgentlessFeatureFlagBackendApi( - proxyApi, this::createDirectApi, eventType.logName()); - } - return proxyApi; - } - - final BackendApi directApi = createDirectApi(); - if (directApi != null) { - return directApi; - } - - LOGGER.warn( - "Feature Flagging {} delivery is disabled because no compatible local EVP proxy or direct intake credentials are available", - eventType.logName()); - return null; - } - - private boolean hasDirectCredentials() { - final String apiKey = config.getApiKey(); - return apiKey != null && !apiKey.isEmpty(); - } - - @Nullable - private BackendApi createDirectApi() { - if (!hasDirectCredentials()) { - return null; - } - try { - return backendApiFactory.createDirectIntakeApi( - Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled(), false); - } catch (final IllegalArgumentException exception) { - LOGGER.debug( - "Cannot configure direct Feature Flagging {} delivery", eventType.logName(), exception); - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java index dee8b424fa3..8c7ce73d8b4 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEventType.java @@ -3,23 +3,17 @@ /** Defines event-specific transport behavior for Feature Flag delivery. */ enum FeatureFlagEventType { // Keep the established exposure transport behavior for compatibility. - EXPOSURE("exposure", true), + EXPOSURE(true), // Flag evaluation writers ignore successful response bodies, so gzip negotiation adds no value. - FLAG_EVALUATION("flag evaluation", false); + FLAG_EVALUATION(false); - private final String logName; private final boolean responseCompressionEnabled; - FeatureFlagEventType(final String logName, final boolean responseCompressionEnabled) { - this.logName = logName; + FeatureFlagEventType(final boolean responseCompressionEnabled) { this.responseCompressionEnabled = responseCompressionEnabled; } - String logName() { - return logName; - } - boolean responseCompressionEnabled() { return responseCompressionEnabled; } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java deleted file mode 100644 index c964efa6c7f..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpContext.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.datadog.featureflag; - -import datadog.trace.api.Config; -import java.util.HashMap; -import java.util.Map; - -final class FeatureFlagEvpContext { - - private FeatureFlagEvpContext() {} - - static Map from(final Config config) { - final Map context = new HashMap<>(4); - context.put("service", config.getServiceName() == null ? "unknown" : config.getServiceName()); - if (config.getEnv() != null) { - context.put("env", config.getEnv()); - } - if (config.getVersion() != null) { - context.put("version", config.getVersion()); - } - return context; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java deleted file mode 100644 index 8a024b52f43..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagEvpPublisher.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.trace.api.intake.Intake; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.util.function.Supplier; -import okhttp3.MediaType; -import okhttp3.RequestBody; - -final class FeatureFlagEvpPublisher { - - private static final MediaType JSON = MediaType.parse("application/json"); - - private final Supplier backendApiSupplier; - private final JsonAdapter jsonAdapter; - private BackendApi evp; - - FeatureFlagEvpPublisher(final BackendApiFactory backendApiFactory, final Class requestType) { - this(backendApiFactory, requestType, true); - } - - FeatureFlagEvpPublisher( - final BackendApiFactory backendApiFactory, - final Class requestType, - final boolean responseCompression) { - this( - () -> backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM, responseCompression), - requestType); - } - - FeatureFlagEvpPublisher( - final Supplier backendApiSupplier, final Class requestType) { - this.backendApiSupplier = backendApiSupplier; - this.jsonAdapter = new Moshi.Builder().build().adapter(requestType); - } - - boolean start() { - if (evp == null) { - evp = backendApiSupplier.get(); - } - return evp != null; - } - - void post(final String route, final T request) throws IOException { - post(route, serialize(request)); - } - - byte[] serialize(final T request) { - return utf8Bytes(jsonAdapter.toJson(request)); - } - - void post(final String route, final byte[] json) throws IOException { - if (!start()) { - throw new IllegalStateException("EVP Proxy not available"); - } - final RequestBody requestBody = RequestBody.create(JSON, json); - evp.post(route, requestBody, stream -> null, null, false); - } - - static byte[] utf8Bytes(final String json) { - try { - return json.getBytes("UTF-8"); - } catch (UnsupportedEncodingException e) { - throw new AssertionError("UTF-8 must be available", e); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java new file mode 100644 index 00000000000..fdb03ee214e --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java @@ -0,0 +1,140 @@ +package com.datadog.featureflag; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; + +import datadog.communication.BackendApiFactory; +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.remoteconfig.ConfigurationPoller; +import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.telemetry.CoreMetricCollector; +import java.io.IOException; +import java.util.function.Consumer; +import javax.annotation.Nullable; + +/** The agent services exposed to the Feature Flags SDK instrumentation. */ +public final class FeatureFlagsBackend implements FeatureFlaggingGateway.Backend, AutoCloseable { + private static final String API_KEY = "api-key"; + private static final String SITE = "site"; + private static final String ENV = "env"; + private static final String SERVICE = "service"; + private static final String VERSION = "version"; + + private final SharedCommunicationObjects sco; + private final Config config; + private final EventProxy eventProxy; + private final SpanEnrichmentWriter spanEnrichmentWriter; + private final CoreMetricCollector metrics; + private RemoteConfigServiceImpl remoteConfig; + + public FeatureFlagsBackend(final SharedCommunicationObjects sco, final Config config) { + this( + sco, + config, + new EventProxy( + new BackendApiFactory(config, sco), + CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) + && config.getApiKey() != null), + SpanEnrichmentWriter.getInstance(), + CoreMetricCollector.getInstance()); + } + + FeatureFlagsBackend( + final SharedCommunicationObjects sco, + final Config config, + final EventProxy eventProxy, + final SpanEnrichmentWriter spanEnrichmentWriter, + final CoreMetricCollector metrics) { + this.sco = sco; + this.config = config; + this.eventProxy = eventProxy; + this.spanEnrichmentWriter = spanEnrichmentWriter; + this.metrics = metrics; + } + + /** Registers the Remote Configuration product early when it is the configuration source. */ + public void start() { + if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals( + this.config.getFeatureFlaggingConfigurationSource()) + && isRemoteConfigAvailable()) { + remoteConfig().start(); + } + } + + @Nullable + @Override + public String setting(final String key) { + // Settings with agent defaults or derived values resolve through the agent configuration. + switch (key) { + case API_KEY: + return this.config.getApiKey(); + case SITE: + return this.config.getSite(); + case ENV: + return this.config.getEnv(); + case SERVICE: + return this.config.getServiceName(); + case VERSION: + return this.config.getVersion(); + default: + return this.config.configProvider().getString(key); + } + } + + @Override + public boolean isRemoteConfigAvailable() { + return this.config.isRemoteConfigEnabled(); + } + + @Override + public AutoCloseable subscribeRemoteConfig(final Consumer listener) { + return remoteConfig().subscribe(listener); + } + + private synchronized RemoteConfigServiceImpl remoteConfig() { + if (this.remoteConfig == null) { + final ConfigurationPoller poller = this.sco.configurationPoller(this.config); + if (poller == null) { + throw new IllegalStateException("Remote Configuration is not available"); + } + this.remoteConfig = new RemoteConfigServiceImpl(poller); + } + return this.remoteConfig; + } + + @Override + public boolean isEventProxyAvailable() { + return this.eventProxy.isAvailable(); + } + + @Override + public boolean postEvent(final String route, final byte[] json) throws IOException { + return this.eventProxy.post(route, json); + } + + @Override + public void count(final String metric, final long value, @Nullable final String reason) { + this.metrics.count(metric, value, reason == null ? null : "reason:" + reason); + } + + @Override + public void enrichSerialId( + final int serialId, final boolean doLog, @Nullable final String targetingKey) { + this.spanEnrichmentWriter.serialId(serialId, doLog, targetingKey); + } + + @Override + public void enrichRuntimeDefault(final String flagKey, @Nullable final Object defaultValue) { + this.spanEnrichmentWriter.runtimeDefault(flagKey, defaultValue); + } + + @Override + public synchronized void close() { + if (this.remoteConfig != null) { + this.remoteConfig.close(); + this.remoteConfig = null; + } + this.spanEnrichmentWriter.close(); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java deleted file mode 100644 index 0a47d316075..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/JsonApiUfcResponseParser.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonReader; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; - -final class JsonApiUfcResponseParser { - - private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; - private static final JsonReader.Options RESPONSE_FIELDS = JsonReader.Options.of("data"); - private static final JsonReader.Options DATA_FIELDS = JsonReader.Options.of("type", "attributes"); - - static final JsonApiUfcResponseParser INSTANCE = - new JsonApiUfcResponseParser(UniversalFlagConfigParser.INSTANCE); - - private final UniversalFlagConfigParser ufcParser; - - JsonApiUfcResponseParser(final UniversalFlagConfigParser ufcParser) { - this.ufcParser = ufcParser; - } - - @Nullable - ServerConfiguration parse(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - if (reader.selectName(RESPONSE_FIELDS) == 0) { - configuration = parseData(reader); - } else { - reader.skipName(); - reader.skipValue(); - } - } - reader.endObject(); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - private ServerConfiguration parseData(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - String type = null; - ServerConfiguration configuration = null; - reader.beginObject(); - while (reader.hasNext()) { - switch (reader.selectName(DATA_FIELDS)) { - case 0: - if (reader.peek() == JsonReader.Token.STRING) { - type = reader.nextString(); - } else { - reader.skipValue(); - } - break; - case 1: - configuration = ufcParser.parse(reader); - break; - default: - reader.skipName(); - reader.skipValue(); - } - } - reader.endObject(); - return UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(type) - ? validConfiguration(configuration) - : null; - } - - @Nullable - private static ServerConfiguration validConfiguration( - @Nullable final ServerConfiguration configuration) { - return configuration != null && configuration.flags != null ? configuration : null; - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index e66a6231990..cdfdc72278b 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -1,44 +1,88 @@ package com.datadog.featureflag; -import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; import datadog.remoteconfig.ConfigurationChangesTypedListener; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.PollingRateHinter; import datadog.remoteconfig.Product; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -public class RemoteConfigServiceImpl - implements ConfigurationSourceService, ConfigurationChangesTypedListener { +/** + * Forwards the raw Feature Flags Remote Configuration documents to the SDK subscribers. The SDK + * owns the document format, so the agent does not parse it. + */ +public class RemoteConfigServiceImpl implements ConfigurationChangesTypedListener { + private static final Logger LOGGER = LoggerFactory.getLogger(RemoteConfigServiceImpl.class); private final ConfigurationPoller configurationPoller; + private final List> subscribers = new CopyOnWriteArrayList<>(); + private volatile byte[] current; + private boolean started; - public RemoteConfigServiceImpl(final SharedCommunicationObjects sco, final Config config) { - configurationPoller = sco.configurationPoller(config); + public RemoteConfigServiceImpl(final ConfigurationPoller configurationPoller) { + this.configurationPoller = configurationPoller; } - @Override - public void init() { - configurationPoller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - configurationPoller.addListener(Product.FFE_FLAGS, UniversalFlagConfigParser.INSTANCE, this); - configurationPoller.start(); + /** + * Registers the Feature Flags product. Registering at agent startup lets the first Remote + * Configuration poll ask for it. + */ + public synchronized void start() { + if (this.started) { + return; + } + this.started = true; + this.configurationPoller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + this.configurationPoller.addListener(Product.FFE_FLAGS, content -> content, this); + this.configurationPoller.start(); } - @Override - public void close() { - configurationPoller.removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - configurationPoller.removeListeners(Product.FFE_FLAGS); - configurationPoller.stop(); + /** + * Subscribes to the configuration documents. + * + * @param subscriber the subscriber, receiving the current document if any. + * @return the handle to close to unsubscribe. + */ + public AutoCloseable subscribe(final Consumer subscriber) { + start(); + this.subscribers.add(subscriber); + final byte[] document = this.current; + if (document != null) { + subscriber.accept(document); + } + return () -> this.subscribers.remove(subscriber); + } + + /** Unregisters the Feature Flags product and drops the subscribers. */ + public synchronized void close() { + if (!this.started) { + return; + } + this.started = false; + this.configurationPoller.removeCapabilities( + Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + this.configurationPoller.removeListeners(Product.FFE_FLAGS); + this.subscribers.clear(); + this.current = null; } @Override public void accept( final String configKey, - @Nullable final ServerConfiguration configuration, + @Nullable final byte[] content, final PollingRateHinter pollingRateHinter) { - FeatureFlaggingGateway.dispatch(configuration); + this.current = content; + for (final Consumer subscriber : this.subscribers) { + try { + subscriber.accept(content); + } catch (final RuntimeException e) { + LOGGER.debug("Feature Flags configuration subscriber failed", e); + } + } } } 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 index f97259d9368..e9c1200868e 100644 --- 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 @@ -1,8 +1,6 @@ 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; @@ -11,10 +9,9 @@ /** * 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. + * capture-vs-write split: it receives the evaluations captured by the {@code dd-openfeature} SDK + * through its instrumentation, 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 @@ -22,18 +19,17 @@ * 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. + * registered exactly once and simply resumes on the next recorded evaluation. * *

    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. + * capture hook, so no evaluation is recorded, this writer 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, AutoCloseable { +public final class SpanEnrichmentWriter implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(SpanEnrichmentWriter.class); @@ -106,45 +102,31 @@ private SpanEnrichmentWriter() { 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. + * Drops any residual state. The interceptor stays registered with the tracer (it cannot be + * removed) but goes inert while the state is empty; later evaluations resume enrichment on the + * same interceptor. */ + @Override public void close() { - FeatureFlaggingGateway.removeSpanEnrichmentListener(this); states.clear(); } - @Override - public void accept(final SpanEnrichmentEvent event) { - if (event == null) { - return; - } + /** + * Records an evaluation that resolved to a split with a serial id. + * + * @param serialId the split serial id. + * @param doLog whether the allocation logs exposures, to also record the subject. + * @param targetingKey the optional targeting key of the subject. + */ + public void serialId(final int serialId, final boolean doLog, final String targetingKey) { 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(); + final SpanEnrichmentAccumulator state = activeRootState(); + if (state != null) { state.addSerialId(serialId); - if (event.doLog() && event.targetingKey() != null) { - state.addSubject(event.targetingKey(), serialId); + if (doLog && targetingKey != null) { + state.addSubject(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. @@ -152,6 +134,40 @@ public void accept(final SpanEnrichmentEvent event) { } } + /** + * Records an evaluation that resolved to its runtime default value. + * + * @param flagKey the flag key. + * @param defaultValue the native default value. + */ + public void runtimeDefault(final String flagKey, final Object defaultValue) { + if (flagKey == null) { + return; + } + try { + final SpanEnrichmentAccumulator state = activeRootState(); + if (state != null) { + state.addDefault(flagKey, defaultValue); + } + } catch (final Throwable t) { + log.debug("Span-enrichment accumulation failed", t); + } + } + + private SpanEnrichmentAccumulator activeRootState() { + final AgentSpan root = rootSpanResolver.activeLocalRoot(); + if (root == null) { + return null; // 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 evaluation retries registration. + return null; + } + return states.getOrCreate(root); + } + /** * @return true once the interceptor is registered with the tracer. */ diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java deleted file mode 100644 index 27fd3f70c70..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/UniversalFlagConfigParser.java +++ /dev/null @@ -1,641 +0,0 @@ -package com.datadog.featureflag; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonDataException; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.remoteconfig.ConfigurationDeserializer; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Feature; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ParsedSemver; -import datadog.trace.api.featureflag.ufc.v1.Rule; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.featureflag.ufc.v1.Shard; -import datadog.trace.api.featureflag.ufc.v1.ShardRange; -import datadog.trace.api.featureflag.ufc.v1.Split; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import okio.BufferedSource; -import okio.Okio; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -final class UniversalFlagConfigParser implements ConfigurationDeserializer { - - private static final Logger LOGGER = LoggerFactory.getLogger(UniversalFlagConfigParser.class); - - static final String INVALID_FLAG = "invalid_flag"; - static final String INVALID_SEMVER_COMPARAND = "invalid_semver_comparand"; - private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; - - /** - * Side-channel for tracking flags that failed semver comparand validation during parsing. Cleared - * and populated by {@link #parse(JsonReader)} so that {@link ServerConfiguration#invalidFlags} - * can be set after the Moshi adapter returns. - */ - static final ThreadLocal> INVALID_FLAGS_HOLDER = - ThreadLocal.withInitial(HashMap::new); - - static final UniversalFlagConfigParser INSTANCE = new UniversalFlagConfigParser(); - - private static final Moshi MOSHI = - new Moshi.Builder() - .add(Instant.class, new InstantAdapter()) - .add(ShardAdapter.FACTORY) - .add(AllocationAdapter.FACTORY) - .add(FlagMapAdapter.FACTORY) - .add(LenientBooleanAdapter.FACTORY) - .add(FeatureListAdapter.FACTORY) - .build(); - private static final JsonAdapter V1_ADAPTER = - MOSHI.adapter(ServerConfiguration.class); - - private UniversalFlagConfigParser() {} - - @Override - public ServerConfiguration deserialize(final byte[] content) throws IOException { - try (BufferedSource source = Okio.buffer(Okio.source(new ByteArrayInputStream(content)))) { - final JsonReader reader = JsonReader.of(source); - final ServerConfiguration configuration = parse(reader); - requireEndOfDocument(reader); - return configuration; - } - } - - @Nullable - ServerConfiguration parse(final JsonReader reader) throws IOException { - INVALID_FLAGS_HOLDER.get().clear(); - final ServerConfiguration configuration = V1_ADAPTER.fromJson(reader); - if (configuration != null) { - final Map invalid = new HashMap<>(INVALID_FLAGS_HOLDER.get()); - if (!invalid.isEmpty()) { - configuration.invalidFlags = invalid; - } - } - INVALID_FLAGS_HOLDER.get().clear(); - return configuration; - } - - private static void requireEndOfDocument(final JsonReader reader) throws IOException { - // A strict JsonReader throws if another top-level value follows the parsed document. - reader.peek(); - } - - /** Converts a UFC uint32 wire value to its binary-compatible int representation. */ - private static int toUnsignedInt(@Nullable final Long value, final String fieldName) { - if (value == null) { - return 0; - } - if (value < 0 || value > MAX_UNSIGNED_INT) { - throw new InvalidFlagException("flag contains an invalid " + fieldName + " value"); - } - return value.intValue(); - } - - /** Validates the required nested UFC fields and SemVer comparands for a flag. */ - private static void validateFlag(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (final Allocation allocation : flag.allocations) { - if (allocation == null) { - continue; - } - validateConditionOperands(flagKey, allocation); - if (allocation.splits == null) { - continue; - } - for (final Split split : allocation.splits) { - if (split == null) { - continue; - } - if (split.shards == null) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" contains a split with missing shards"); - } - for (final Shard shard : split.shards) { - if (shard == null - || Integer.toUnsignedLong(shard.totalShards) == 0 - || shard.ranges == null) { - throw new InvalidFlagException("flag \"" + flagKey + "\" contains invalid shards"); - } - for (final ShardRange range : shard.ranges) { - if (range == null) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" contains an invalid shard range"); - } - } - } - } - } - validateAndCacheSemverComparands(flagKey, flag); - } - - private static void validateConditionOperands(final String flagKey, final Allocation allocation) { - if (allocation.rules == null) { - return; - } - for (final Rule rule : allocation.rules) { - if (rule == null || rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition == null || condition.operator == null) { - continue; - } - switch (condition.operator) { - case LT: - case LTE: - case GT: - case GTE: - if (!(condition.value instanceof Number)) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" has a non-numeric condition"); - } - break; - case ONE_OF: - case NOT_ONE_OF: - if (!(condition.value instanceof List)) { - throw new InvalidFlagException("flag \"" + flagKey + "\" has a non-list condition"); - } - break; - case IS_NULL: - if (!(condition.value instanceof Boolean)) { - throw new InvalidFlagException( - "flag \"" + flagKey + "\" has a non-boolean condition"); - } - break; - default: - break; - } - } - } - } - - /** - * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. Throws {@link - * InvalidSemverComparandException} if any condition has an invalid or non-string comparand value. - */ - private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { - if (flag.allocations == null) { - return; - } - for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { - final Allocation allocation = flag.allocations.get(allocIdx); - if (allocation.rules == null) { - continue; - } - for (final Rule rule : allocation.rules) { - if (rule.conditions == null) { - continue; - } - for (final ConditionConfiguration condition : rule.conditions) { - if (condition.operator == null) { - continue; - } - switch (condition.operator) { - case SEMVER_EQ: - case SEMVER_NEQ: - case SEMVER_LT: - case SEMVER_LTE: - case SEMVER_GT: - case SEMVER_GTE: - if (!(condition.value instanceof String)) { - throw new InvalidSemverComparandException( - "flag \"" - + flagKey - + "\" allocation " - + allocIdx - + " rule has condition with operator \"" - + condition.operator - + "\" that requires string value"); - } - final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); - if (parsed == null) { - throw new InvalidSemverComparandException( - "flag \"" - + flagKey - + "\" allocation " - + allocIdx - + " rule has condition with operator \"" - + condition.operator - + "\" and invalid semantic version \"" - + condition.value - + "\""); - } - condition.semverComparand = parsed; - break; - default: - // Non-semver operators are not validated here - break; - } - } - } - } - } - - /** Thrown when a flag has an invalid UFC shape. */ - static final class InvalidFlagException extends IllegalArgumentException { - InvalidFlagException(final String message) { - super(message); - } - } - - /** Thrown when a SEMVER_* condition has an invalid or non-string comparand value. */ - static final class InvalidSemverComparandException extends IllegalArgumentException { - InvalidSemverComparandException(final String message) { - super(message); - } - } - - /** - * Reads UFC uint32 shard fields through Long first, then preserves the bootstrap model's int ABI - * by storing the accepted value as its two's-complement bit pattern. - */ - static final class ShardAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || type != Shard.class) { - return null; - } - return new ShardAdapter(moshi.adapter(ShardJson.class)); - } - }; - - private final JsonAdapter delegate; - - ShardAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Nullable - @Override - public Shard fromJson(@Nonnull final JsonReader reader) throws IOException { - final ShardJson shard = delegate.fromJson(reader); - if (shard == null) { - return null; - } - final List ranges; - if (shard.ranges == null) { - ranges = null; - } else { - ranges = new ArrayList<>(); - for (final ShardRangeJson range : shard.ranges) { - ranges.add( - range == null - ? null - : new ShardRange( - toUnsignedInt(range.start, "range start"), - toUnsignedInt(range.end, "range end"))); - } - } - return new Shard(shard.salt, ranges, toUnsignedInt(shard.totalShards, "totalShards")); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Shard value) { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class ShardJson { - String salt; - List ranges; - Long totalShards; - } - - static final class ShardRangeJson { - Long start; - Long end; - } - - static final class FlagMapAdapter extends JsonAdapter> { - - private static final Type FLAGS_TYPE = - Types.newParameterizedType(Map.class, String.class, Flag.class); - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, FLAGS_TYPE)) { - return null; - } - return new FlagMapAdapter(moshi.adapter(Flag.class)); - } - }; - - private final JsonAdapter flagAdapter; - - FlagMapAdapter(final JsonAdapter flagAdapter) { - this.flagAdapter = flagAdapter; - } - - @Nullable - @Override - public Map fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - final Map flags = new HashMap<>(); - reader.beginObject(); - while (reader.hasNext()) { - final String flagKey = reader.nextName(); - final Object rawFlag = reader.readJsonValue(); - try { - final Flag flag = flagAdapter.fromJsonValue(rawFlag); - if (flag != null) { - validateFlag(flagKey, flag); - flags.put(flagKey, flag); - } - } catch (JsonDataException | IllegalArgumentException error) { - INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_FLAG); - if (error instanceof InvalidSemverComparandException) { - INVALID_FLAGS_HOLDER.get().put(flagKey, INVALID_SEMVER_COMPARAND); - } - LOGGER.warn( - "Dropping malformed FFE flag {} during remote config deserialization: {}", - flagKey, - error.toString()); - } - } - reader.endObject(); - return flags; - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Map value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - /** - * Reads a boxed Boolean tolerantly: JSON null and wrong-typed values (string, number, object, - * array) map to null rather than aborting the enclosing object's parse. Downstream reads must use - * Boolean.TRUE.equals(field) so null resolves to the privacy-preserving default. - * - *

    Only applies to Boolean.class (not primitive boolean), so mandatory primitive-boolean fields - * (e.g. Flag.enabled) keep their strict parse. - */ - static final class LenientBooleanAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || type != Boolean.class) { - return null; - } - return new LenientBooleanAdapter(); - } - }; - - @Nullable - @Override - public Boolean fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.BOOLEAN) { - return reader.nextBoolean(); - } - // null and every wrong-typed value collapse to null so the caller falls back to its default - // rather than the enclosing config being rejected wholesale. - reader.skipValue(); - return null; - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Boolean value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - /** - * Reads a split's features. Features only feed exposure hooks, so a malformed list or entry is - * dropped instead of rejecting the flag: it must never change which variation a subject gets. - */ - static final class FeatureListAdapter extends JsonAdapter> { - - private static final Type FEATURES_TYPE = Types.newParameterizedType(List.class, Feature.class); - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, FEATURES_TYPE)) { - return null; - } - return new FeatureListAdapter(); - } - }; - - @Nullable - @Override - public List fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { - reader.skipValue(); - return null; - } - final List features = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - final Feature feature = readFeature(reader); - if (feature != null) { - features.add(feature); - } - } - reader.endArray(); - return Collections.unmodifiableList(features); - } - - @Nullable - private static Feature readFeature(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { - reader.skipValue(); - return null; - } - String key = null; - Object value = null; - List destinations = Collections.emptyList(); - reader.beginObject(); - while (reader.hasNext()) { - final String name = reader.nextName(); - if ("key".equals(name) && reader.peek() == JsonReader.Token.STRING) { - key = reader.nextString(); - } else if ("value".equals(name)) { - value = readScalar(reader); - } else if ("destinations".equals(name)) { - destinations = readDestinations(reader); - } else { - reader.skipValue(); - } - } - reader.endObject(); - if (key == null || key.isEmpty() || value == null || destinations.isEmpty()) { - return null; - } - return new Feature(key, value, destinations); - } - - /** Keeps every non-empty string, including destinations this SDK does not know yet. */ - private static List readDestinations(final JsonReader reader) throws IOException { - if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { - reader.skipValue(); - return Collections.emptyList(); - } - final List destinations = new ArrayList<>(); - reader.beginArray(); - while (reader.hasNext()) { - if (reader.peek() == JsonReader.Token.STRING) { - final String destination = reader.nextString(); - if (!destination.isEmpty()) { - destinations.add(destination); - } - } else { - reader.skipValue(); - } - } - reader.endArray(); - return Collections.unmodifiableList(destinations); - } - - @Nullable - private static Object readScalar(final JsonReader reader) throws IOException { - switch (reader.peek()) { - case STRING: - return reader.nextString(); - case NUMBER: - return reader.nextDouble(); - case BOOLEAN: - return reader.nextBoolean(); - default: - reader.skipValue(); - return null; - } - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final List value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class InstantAdapter extends JsonAdapter { - - @Nullable - @Override - public Instant fromJson(@Nonnull final JsonReader reader) throws IOException { - if (reader.peek() == JsonReader.Token.NULL) { - return reader.nextNull(); - } - return parseInstant(reader.nextString()); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Instant value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class AllocationAdapter extends JsonAdapter { - - static final Factory FACTORY = - new Factory() { - @Nullable - @Override - public JsonAdapter create( - @Nonnull final Type type, - @Nonnull final Set annotations, - @Nonnull final Moshi moshi) { - if (!annotations.isEmpty() || !Types.equals(type, Allocation.class)) { - return null; - } - return new AllocationAdapter(moshi.adapter(AllocationJson.class)); - } - }; - - private final JsonAdapter delegate; - - AllocationAdapter(final JsonAdapter delegate) { - this.delegate = delegate; - } - - @Nullable - @Override - public Allocation fromJson(@Nonnull final JsonReader reader) throws IOException { - final AllocationJson allocation = delegate.fromJson(reader); - if (allocation == null) { - return null; - } - return Allocation.fromInstants( - allocation.key, - allocation.rules, - allocation.startAt, - allocation.endAt, - allocation.splits, - allocation.doLog); - } - - @Override - public void toJson(@Nonnull final JsonWriter writer, @Nullable final Allocation value) - throws IOException { - throw new UnsupportedOperationException("Reading only adapter"); - } - } - - static final class AllocationJson { - String key; - List rules; - Instant startAt; - Instant endAt; - List splits; - Boolean doLog; - } - - @Nullable - private static Instant parseInstant(final String date) { - try { - return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(date, Instant::from); - } catch (Exception e) { - // ignore wrongly set dates - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java deleted file mode 100644 index b02bd05642b..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessConfigurationSourceTest.java +++ /dev/null @@ -1,1363 +0,0 @@ -package com.datadog.featureflag; - -import static java.nio.charset.StandardCharsets.UTF_8; -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.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import datadog.communication.http.OkHttpUtils; -import datadog.logging.RatelimitedLogger; -import datadog.trace.agent.test.server.http.JavaTestHttpServer; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.HttpURLConnection; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.zip.GZIPOutputStream; -import okhttp3.Call; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Protocol; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class AgentlessConfigurationSourceTest { - private static final String CONFIG_PATH = "/api/v2/feature-flagging/config/rules-based/server"; - - @Mock private FeatureFlaggingGateway.ConfigListener listener; - - @AfterEach - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener); - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - @Test - void derivesDatadogUfcCdnEndpointFromSiteAndEnv() { - final Config config = config("datad0g.com", "staging env"); - - assertEquals( - "https://ufc-server.ff-cdn.datad0g.com/api/v2/feature-flagging/config/rules-based/server?dd_env=staging%20env", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void derivesDatadogUfcCdnEndpointWithoutEnv() { - assertEquals( - "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config("datadoghq.com", "")).toString()); - assertEquals( - "https://ufc-server.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config("datadoghq.com", null)).toString()); - } - - @Test - void appendsRulesBasedServerPathToConfiguredAgentlessBaseUrl() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://mock-backend:8080"); - - assertEquals( - "http://mock-backend:8080/api/v2/feature-flagging/config/rules-based/server", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void usesConfiguredAgentlessEndpointWithPathUnchanged() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://mock-backend:8080/custom/ufc?tenant=test"); - - assertEquals( - "http://mock-backend:8080/custom/ufc?tenant=test", - AgentlessConfigurationSource.endpoint(config).toString()); - } - - @Test - void rejectsInvalidConfiguredAgentlessBaseUrl() { - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("not a URL"); - - assertThrows( - IllegalArgumentException.class, () -> AgentlessConfigurationSource.endpoint(config)); - } - - @Test - void rejectsInvalidDatadogUfcCdnEndpoint() { - assertThrows( - IllegalArgumentException.class, - () -> AgentlessConfigurationSource.endpoint(config("datadoghq.com:bad", ""))); - } - - @Test - void defaultConstructorBuildsHttpClientFromConfig() { - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource(config("datad0g.com", "staging")); - - service.close(); - } - - @Test - void realHttpClientDoesNotSendApiKeyOverHttp() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> - api.getResponse() - .addHeader("ETag", "etag-b") - .send(emptyConfig()))))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), "etag-a"); - - assertEquals(HttpURLConnection.HTTP_OK, response.status); - assertEquals("etag-b", response.etag); - assertEquals(emptyConfig(), new String(response.body, UTF_8)); - assertNull(server.getLastRequest().getHeader("DD-API-KEY")); - assertEquals("etag-a", server.getLastRequest().getHeader("If-None-Match")); - assertEquals("java", server.getLastRequest().getHeader("Datadog-Meta-Lang")); - assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void sendsApiKeyToDefaultDatadogHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final HttpUrl endpoint = HttpUrl.get("https://ufc-server.ff-cdn.datadoghq.com" + CONFIG_PATH); - - client.fetch(endpoint, config(), null); - - assertEquals("test-api-key", requests.get(0).header("DD-API-KEY")); - } - - @Test - void stripsApiKeyFromCustomHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("https://flags.example.test/custom/ufc"); - final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); - - client.fetch(endpoint, config, null); - - assertNull(requests.get(0).header("DD-API-KEY")); - } - - @Test - void stripsApiKeyFromUnexpectedHttpsEndpoint() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient(requests, delay -> {}, () -> 1.0, response(200, "etag-a", emptyConfig())); - final HttpUrl endpoint = HttpUrl.get("https://flags.example.test/custom/ufc"); - - client.fetch(endpoint, config(), null); - - assertNull(requests.get(0).header("DD-API-KEY")); - } - - @Test - void handlesGzipAndKeepsLastKnownGoodWhenNextResponseIsTruncated() throws Exception { - final byte[] compressedConfig = gzip(emptyConfig()); - final byte[] truncatedConfig = Arrays.copyOf(compressedConfig, compressedConfig.length - 8); - final AtomicInteger responses = new AtomicInteger(); - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - final boolean firstResponse = responses.getAndIncrement() == 0; - final byte[] body = - firstResponse ? compressedConfig : truncatedConfig; - api.getResponse() - .addHeader("Content-Encoding", "gzip") - .addHeader("ETag", firstResponse ? "etag-good" : "etag-bad") - .sendWithType("application/json", body); - })))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - endpoint, - config(), - 30_000, - new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, delay -> {}, () -> 1.0), - Executors.newSingleThreadScheduledExecutor()); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("Staging", configuration.getValue().environment.name); - assertEquals(4, responses.get()); - assertEquals("gzip", server.getLastRequest().getHeader("Accept-Encoding")); - assertEquals("etag-good", server.getLastRequest().getHeader("If-None-Match")); - } finally { - service.close(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void downloadsAndAppliesLargeUfcWithoutPayloadLimit() throws Exception { - final int flagCount = 5_000; - final String largeConfig = largeConfig(flagCount); - assertTrue(largeConfig.getBytes(UTF_8).length > 500_000); - - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> s.handlers(h -> h.get(CONFIG_PATH, api -> api.getResponse().send(largeConfig))))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - endpoint, - config(), - 30_000, - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient), - Executors.newSingleThreadScheduledExecutor()); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertTrue(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals(flagCount, configuration.getValue().flags.size()); - } finally { - service.close(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientAllowsMissingEtagAndEmptyResponseBody() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> - api.getResponse() - .status(HttpURLConnection.HTTP_NO_CONTENT) - .send())))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null); - - assertEquals(HttpURLConnection.HTTP_NO_CONTENT, response.status); - assertNull(response.etag); - assertEquals(0, response.body.length); - assertNull(server.getLastRequest().getHeader("If-None-Match")); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void httpClientAdapterPreservesMissingResponseBody() throws Exception { - final OkHttpClient httpClient = mock(OkHttpClient.class); - final Call call = mock(Call.class); - final HttpUrl endpoint = HttpUrl.get("http://localhost"); - final okhttp3.Request request = new okhttp3.Request.Builder().url(endpoint).build(); - final Response okHttpResponse = - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(HttpURLConnection.HTTP_OK) - .message("OK") - .build(); - when(httpClient.newCall(any())).thenReturn(call); - when(call.execute()).thenReturn(okHttpResponse); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - final AgentlessConfigurationSource.UfcHttpResponse response = - client.fetch(endpoint, config(), null); - - assertEquals(HttpURLConnection.HTTP_OK, response.status); - assertNull(response.etag); - assertNull(response.body); - } - - @Test - void realHttpClientCancellationInterruptsInFlightRequest() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - requestStarted.countDown(); - assertTrue(releaseRequest.await(1, TimeUnit.SECONDS)); - api.getResponse().send(emptyConfig()); - })))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future response = - runner.submit( - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - assertThrows( - IllegalStateException.class, - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - - client.cancel(); - - final ExecutionException failure = - assertThrows(ExecutionException.class, () -> response.get(1, TimeUnit.SECONDS)); - assertInstanceOf(IOException.class, failure.getCause()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientCancellationBeforeFetchPreventsRequest() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> h.get(CONFIG_PATH, api -> api.getResponse().send(emptyConfig()))))) { - final OkHttpClient httpClient = new OkHttpClient.Builder().build(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient(httpClient); - - try { - client.cancel(); - - assertThrows( - IOException.class, - () -> - client.fetch( - HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)), config(), null)); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void realHttpClientTimesOutDelayedResponse() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.get( - CONFIG_PATH, - api -> { - TimeUnit.MILLISECONDS.sleep(500); - api.getResponse().send(emptyConfig()); - })))) { - final HttpUrl endpoint = HttpUrl.get(server.getAddress().resolve(CONFIG_PATH)); - final OkHttpClient httpClient = OkHttpUtils.buildHttpClient(endpoint, 50); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, delay -> {}, () -> 1.0); - - try { - assertThrows(IOException.class, () -> client.fetch(endpoint, config(), null)); - } finally { - httpClient.dispatcher().executorService().shutdownNow(); - httpClient.connectionPool().evictAll(); - } - } - } - - @Test - void appliesAcceptedJsonApiUfcThroughGateway() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("2026-07-15T19:57:07.219869778Z", configuration.getValue().createdAt); - assertNull(configuration.getValue().format); - assertEquals("Staging", configuration.getValue().environment.name); - assertTrue(configuration.getValue().flags.isEmpty()); - assertNull(client.requests.get(0).etag); - } - - @Test - void customEndpointRequiresJsonApiAndKeepsLastKnownGoodOnRawUfc() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-good", emptyConfig()), - response(200, "etag-raw", emptyConfigAttributes())); - final Config config = config(); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()) - .thenReturn("http://custom-backend/custom/ufc"); - final AgentlessConfigurationSource service = service(client, config); - final ArgumentCaptor configuration = - ArgumentCaptor.forClass(ServerConfiguration.class); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(configuration.capture()); - assertEquals("Staging", configuration.getValue().environment.name); - assertNull(client.requests.get(0).etag); - assertEquals("etag-good", client.requests.get(1).etag); - } - - @Test - void ignoresBlankEtag() throws Exception { - final FakeClient client = - new FakeClient(response(200, " ", emptyConfig()), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void successfulResponseWithoutEtagClearsPreviousEtag() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), - response(200, null, emptyConfig()), - response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertEquals("etag-a", client.requests.get(1).etag); - assertNull(client.requests.get(2).etag); - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - } - - @Test - void usesEtagAndSkipsDispatchOnUnchangedConfig() throws Exception { - final FakeClient client = - new FakeClient(response(200, "etag-a", emptyConfig()), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - verify(listener).accept(any(ServerConfiguration.class)); - assertEquals("etag-a", client.requests.get(1).etag); - } - - @Test - void coldNotModifiedDoesNotEstablishEtag() throws Exception { - final FakeClient client = - new FakeClient(response(304, "etag-cold", null), response(200, "etag-warm", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void failedGatewayDispatchDoesNotAdvanceEtag() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - final FeatureFlaggingGateway.ConfigListener failingListener = - configuration -> { - throw new IllegalStateException("listener rejected configuration"); - }; - FeatureFlaggingGateway.addConfigListener(failingListener); - - try { - assertThrows(IllegalStateException.class, service::pollOnce); - FeatureFlaggingGateway.removeConfigListener(failingListener); - - assertTrue(service.pollOnce()); - - assertNull(client.requests.get(1).etag); - } finally { - FeatureFlaggingGateway.removeConfigListener(failingListener); - } - } - - @Test - void keepsLastKnownGoodOnAuthFailureAndMalformedPayload() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-good", emptyConfig()), - response(401, null, null), - response(200, null, "{not-json}"), - response(200, null, "{\"flags\":[]}")); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener).accept(any(ServerConfiguration.class)); - assertEquals("etag-good", client.requests.get(1).etag); - assertEquals("etag-good", client.requests.get(2).etag); - assertEquals("etag-good", client.requests.get(3).etag); - } - - @Test - void rejectsForbiddenNonOkMissingBodyAndNullConfiguration() throws Exception { - final FakeClient client = - new FakeClient( - response(403, null, null), - response(404, null, null), - response(600, null, null), - response(200, null, null), - response(200, null, "null"), - response(200, null, jsonApiResponse("other-configuration", emptyConfigAttributes())), - response(200, null, "{\"data\":null}"), - response( - 200, null, "{\"data\":{\"id\":\"1\",\"type\":\"universal-flag-configuration\"}}"), - response(200, null, emptyConfigAttributes())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verifyNoInteractions(listener); - } - - @Test - void warnsRateLimitedOnUnauthorizedAndForbidden() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final FakeClient client = new FakeClient(response(401, null, null), response(403, null, null)); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - - try { - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - HttpURLConnection.HTTP_UNAUTHORIZED); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint returned HTTP {}; verify endpoint authentication", - HttpURLConnection.HTTP_FORBIDDEN); - } finally { - service.close(); - } - } - - @Test - void retriesTimeoutBeforeApplyingConfig() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void retriesClientTimeoutAndRateLimitStatusBeforeApplyingConfig() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(408, null, null), - response(200, "etag-a", emptyConfig()), - response(429, null, null), - response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - - assertEquals(4, requests.size()); - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - } - - @Test - void retriesServerErrorThenKeepsColdStateOnNotModified() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, delay -> {}, () -> 1.0, response(500, null, null), response(304, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(2, requests.size()); - verifyNoInteractions(listener); - } - - @Test - void warnsRateLimitedAfterRetryableFailuresAreExhausted() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(503, null, null), - response(503, null, null), - response(503, null, null)); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertFalse(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint failed after {} attempts with HTTP {}", 3, 503); - verifyNoInteractions(listener); - } finally { - service.close(); - } - } - - @Test - void warnsRateLimitedAfterIoFailuresAreExhausted() throws Exception { - final RatelimitedLogger ratelimitedLogger = mock(RatelimitedLogger.class); - final SocketTimeoutException finalFailure = - new SocketTimeoutException("slow HTTP configuration source"); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - new SocketTimeoutException("slow HTTP configuration source"), - finalFailure); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - executor, - ratelimitedLogger); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - assertFalse(service.pollOnce()); - - assertEquals(3, requests.size()); - verify(ratelimitedLogger) - .warn( - "Feature Flagging agentless endpoint request failed after {} attempts", - 3, - finalFailure); - verifyNoInteractions(listener); - } finally { - service.close(); - } - } - - @Test - void usesIntervalAwareRetryBackoff() throws Exception { - final List delays = new ArrayList<>(); - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delays::add, - () -> 1.0, - response(503, null, null), - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - - assertEquals(Arrays.asList(5_000L, 10_000L), delays); - assertEquals(3, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void retryPolicyRejectsNonIoAndInterruptedIoFailures() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean()); - - assertFalse(policy.shouldRetry(new IllegalStateException("not an I/O failure"))); - - Thread.currentThread().interrupt(); - try { - assertFalse(policy.shouldRetry(new IOException("interrupted"))); - } finally { - assertTrue(Thread.interrupted()); - } - } - - @Test - void retryPolicyRejectsMissingResponse() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean()); - - assertFalse(policy.shouldRetry((Response) null)); - } - - @Test - void cancelledRetryPolicyRejectsBackoff() { - final AgentlessConfigurationSource.AgentlessRetryPolicy policy = - retryPolicy(new AtomicBoolean(true)); - - assertThrows(InterruptedIOException.class, policy::backoff); - } - - @Test - void clampsAndJittersRetryBackoff() { - assertEquals(2_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 1, 1.0)); - assertEquals(5_000, AgentlessConfigurationSource.retryDelayMillis(1_000, 2, 1.0)); - assertEquals(10_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 1, 1.0)); - assertEquals(30_000, AgentlessConfigurationSource.retryDelayMillis(600_000, 2, 1.0)); - assertEquals(6_000, AgentlessConfigurationSource.retryDelayMillis(30_000, 1, 1.2)); - assertThrows( - IllegalArgumentException.class, - () -> AgentlessConfigurationSource.retryDelayMillis(30_000, 3, 1.0)); - } - - @Test - void rejectsOverlappingPolls() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newFixedThreadPool(2); - - try { - final Future first = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - final Future second = runner.submit(service::pollOnce); - - assertFalse(second.get(1, TimeUnit.SECONDS)); - releaseRequest.countDown(); - assertTrue(first.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.calls.get()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void initCompletesFirstPollAndCloseCancelsScheduledFuture() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 60_000, - client, - Executors.newSingleThreadScheduledExecutor()); - FeatureFlaggingGateway.addConfigListener(listener); - - service.init(); - assertEquals(1, client.calls.get()); - service.close(); - - verify(listener).accept(any(ServerConfiguration.class)); - } - - @Test - void initCompletesInitialRetryCycleBeforeReturning() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(500, null, null), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 60_000, - client, - Executors.newSingleThreadScheduledExecutor()); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - service.init(); - - assertEquals(2, requests.size()); - verify(listener).accept(any(ServerConfiguration.class)); - } finally { - service.close(); - } - } - - @Test - void repeatedInitStartsOnlyOnePoller() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.init(); - service.init(); - awaitCalls(client, 1); - service.close(); - - assertEquals(1, client.calls.get()); - } - - @Test - void scheduledPollContinuesAfterListenerRuntimeException() throws Exception { - final FakeClient client = - new FakeClient( - response(200, "etag-a", emptyConfig()), response(200, "etag-b", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 10, - client, - Executors.newSingleThreadScheduledExecutor()); - final AtomicInteger listenerCalls = new AtomicInteger(); - // Wait on the listener rather than on FakeClient.calls: the call counter is incremented when - // a request starts, so it reaches 2 before the second configuration has been applied. - final CountDownLatch listenerNotified = new CountDownLatch(2); - final FeatureFlaggingGateway.ConfigListener flakyListener = - configuration -> { - final int call = listenerCalls.incrementAndGet(); - listenerNotified.countDown(); - if (call == 1) { - throw new IllegalStateException("listener rejected first configuration"); - } - }; - FeatureFlaggingGateway.addConfigListener(flakyListener); - - try { - service.init(); - assertTrue(listenerNotified.await(5, TimeUnit.SECONDS)); - - assertEquals(2, listenerCalls.get()); - assertNull(client.requests.get(1).etag); - } finally { - service.close(); - FeatureFlaggingGateway.removeConfigListener(flakyListener); - } - } - - @Test - void closeCancelsInFlightRequestAndIgnoresLateSuccess() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - FeatureFlaggingGateway.addConfigListener(listener); - - try { - final Future poll = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - assertFalse(poll.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.cancelCalls.get()); - assertEquals(1, client.calls.get()); - verifyNoInteractions(listener); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void closeDuringIoFailurePreventsRetry() throws Exception { - final CountDownLatch requestStarted = new CountDownLatch(1); - final CountDownLatch releaseRequest = new CountDownLatch(1); - final FakeClient client = - new FakeClient(new SocketTimeoutException("slow HTTP configuration source")); - client.block(requestStarted, releaseRequest); - final AgentlessConfigurationSource service = service(client); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future poll = runner.submit(service::pollOnce); - assertTrue(requestStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - assertFalse(poll.get(1, TimeUnit.SECONDS)); - assertEquals(1, client.calls.get()); - } finally { - releaseRequest.countDown(); - runner.shutdownNow(); - } - } - - @Test - void closeInterruptsRetryBackoff() throws Exception { - final CountDownLatch backoffStarted = new CountDownLatch(1); - final List requests = new ArrayList<>(); - final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> { - backoffStarted.countDown(); - TimeUnit.MINUTES.sleep(1); - }, - () -> 1.0, - new SocketTimeoutException("slow HTTP configuration source"), - response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = - new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), config(), 30_000, client, executor); - final ExecutorService runner = Executors.newSingleThreadExecutor(); - - try { - final Future initialization = runner.submit(service::init); - assertTrue(backoffStarted.await(1, TimeUnit.SECONDS)); - - service.close(); - - initialization.get(1, TimeUnit.SECONDS); - assertTrue(executor.awaitTermination(1, TimeUnit.SECONDS)); - assertEquals(1, requests.size()); - } finally { - runner.shutdownNow(); - } - } - - @Test - void closePreventsFurtherPolls() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.close(); - - assertFalse(service.pollOnce()); - assertEquals(0, client.calls.get()); - } - - @Test - void initAfterCloseDoesNotSchedulePoll() throws Exception { - final FakeClient client = new FakeClient(response(200, "etag-a", emptyConfig())); - final AgentlessConfigurationSource service = service(client); - - service.close(); - service.init(); - - assertEquals(0, client.calls.get()); - } - - @Nested - class SystemTestParity { - @Test - void preservesSystemTestSourceTransitionsAndLastKnownGoodState() throws Exception { - final List requests = new ArrayList<>(); - final AgentlessConfigurationSource.OkHttpUfcHttpClient client = - scriptedClient( - requests, - delay -> {}, - () -> 1.0, - response(200, "etag-a", emptyConfig()), - response(304, "etag-must-not-replace-a", null), - response(509, null, null), - response(200, "etag-b", emptyConfig()), - response(200, "etag-c", "{not-json}"), - response(401, null, null)); - final AgentlessConfigurationSource service = service(client); - FeatureFlaggingGateway.addConfigListener(listener); - - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertTrue(service.pollOnce()); - assertFalse(service.pollOnce()); - assertFalse(service.pollOnce()); - - verify(listener, times(2)).accept(any(ServerConfiguration.class)); - assertEquals("etag-a", requests.get(1).header("If-None-Match")); - assertEquals("etag-a", requests.get(2).header("If-None-Match")); - assertEquals("etag-a", requests.get(3).header("If-None-Match")); - assertEquals("etag-b", requests.get(4).header("If-None-Match")); - assertEquals("etag-b", requests.get(5).header("If-None-Match")); - } - } - - private static AgentlessConfigurationSource service( - final AgentlessConfigurationSource.UfcHttpClient client) { - return new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config(), - 30_000, - client, - Executors.newSingleThreadScheduledExecutor()); - } - - private static AgentlessConfigurationSource service( - final FakeClient client, final Config config) { - return new AgentlessConfigurationSource( - HttpUrl.get("http://localhost" + CONFIG_PATH), - config, - 30_000, - client, - Executors.newSingleThreadScheduledExecutor()); - } - - private static AgentlessConfigurationSource.OkHttpUfcHttpClient scriptedClient( - final List requests, - final AgentlessConfigurationSource.RetrySleeper retrySleeper, - final java.util.function.DoubleSupplier jitter, - final Object... outcomes) - throws IOException { - final BlockingQueue scriptedOutcomes = new LinkedBlockingQueue<>(); - scriptedOutcomes.addAll(Arrays.asList(outcomes)); - final OkHttpClient httpClient = mock(OkHttpClient.class); - when(httpClient.newCall(any())) - .thenAnswer( - invocation -> { - final okhttp3.Request request = invocation.getArgument(0); - requests.add(request); - final Object outcome = scriptedOutcomes.remove(); - final Call call = mock(Call.class); - when(call.execute()) - .thenAnswer( - ignored -> { - if (outcome instanceof IOException) { - throw (IOException) outcome; - } - return okHttpResponse( - request, (AgentlessConfigurationSource.UfcHttpResponse) outcome); - }); - return call; - }); - return new AgentlessConfigurationSource.OkHttpUfcHttpClient( - httpClient, 30_000, retrySleeper, jitter); - } - - private static AgentlessConfigurationSource.AgentlessRetryPolicy retryPolicy( - final AtomicBoolean cancelled) { - return new AgentlessConfigurationSource.AgentlessRetryPolicy( - cancelled, 30_000, delay -> {}, () -> 1.0); - } - - private static Response okHttpResponse( - final okhttp3.Request request, final AgentlessConfigurationSource.UfcHttpResponse response) { - final Response.Builder builder = - new Response.Builder() - .request(request) - .protocol(Protocol.HTTP_1_1) - .code(response.status) - .message(Integer.toString(response.status)); - if (response.etag != null) { - builder.header("ETag", response.etag); - } - if (response.body != null) { - builder.body(ResponseBody.create(MediaType.get("application/json"), response.body)); - } - return builder.build(); - } - - private static Config config() { - return config("datadoghq.com", ""); - } - - private static Config config(final String site, final String env) { - final Config config = mock(Config.class); - lenient() - .when(config.getFeatureFlaggingConfigurationSourcePollIntervalSeconds()) - .thenReturn(30); - lenient() - .when(config.getFeatureFlaggingConfigurationSourceRequestTimeoutSeconds()) - .thenReturn(5); - lenient().when(config.getFeatureFlaggingConfigurationSourceAgentlessBaseUrl()).thenReturn(null); - lenient().when(config.getApiKey()).thenReturn("test-api-key"); - lenient().when(config.getSite()).thenReturn(site); - lenient().when(config.getEnv()).thenReturn(env); - return config; - } - - private static AgentlessConfigurationSource.UfcHttpResponse response( - final int status, final String etag, final String body) { - return new AgentlessConfigurationSource.UfcHttpResponse( - status, etag, body == null ? null : body.getBytes(UTF_8)); - } - - private static String emptyConfig() { - return jsonApiResponse("universal-flag-configuration", emptyConfigAttributes()); - } - - private static String emptyConfigAttributes() { - return "{" - + "\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," - + "\"environment\":{\"name\":\"Staging\"}," - + "\"flags\":{}" - + "}"; - } - - private static String jsonApiResponse(final String type, final String attributes) { - return "{\"data\":{" - + "\"id\":\"1\"," - + "\"type\":\"" - + type - + "\"," - + "\"attributes\":" - + attributes - + "}}"; - } - - private static String largeConfig(final int flagCount) { - final StringBuilder json = - new StringBuilder( - "{\"createdAt\":\"2026-07-15T19:57:07.219869778Z\"," - + "\"environment\":{\"name\":\"Large Test\"}," - + "\"flags\":{"); - for (int index = 0; index < flagCount; index++) { - if (index > 0) { - json.append(','); - } - final String flagKey = "large-flag-" + index; - json.append('"') - .append(flagKey) - .append("\":{\"key\":\"") - .append(flagKey) - .append( - "\",\"enabled\":true,\"variationType\":\"STRING\"," - + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":\"on\"}}," - + "\"allocations\":[]}"); - } - return jsonApiResponse("universal-flag-configuration", json.append("}}").toString()); - } - - private static byte[] gzip(final String value) throws IOException { - final ByteArrayOutputStream output = new ByteArrayOutputStream(); - try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { - gzip.write(value.getBytes(UTF_8)); - } - return output.toByteArray(); - } - - private static void awaitCalls(final FakeClient client, final int count) throws Exception { - for (int i = 0; i < 100; i++) { - if (client.calls.get() >= count) { - return; - } - TimeUnit.MILLISECONDS.sleep(10); - } - assertEquals(count, client.calls.get()); - } - - private static final class FakeClient implements AgentlessConfigurationSource.UfcHttpClient { - private final AtomicInteger calls = new AtomicInteger(); - private final AtomicInteger cancelCalls = new AtomicInteger(); - private final List requests = new ArrayList<>(); - private final BlockingQueue responses = new LinkedBlockingQueue<>(); - private CountDownLatch requestStarted; - private CountDownLatch releaseRequest; - - private FakeClient(final Object... responses) { - for (final Object response : responses) { - this.responses.add(response); - } - } - - private void block(final CountDownLatch requestStarted, final CountDownLatch releaseRequest) { - this.requestStarted = requestStarted; - this.releaseRequest = releaseRequest; - } - - @Override - public AgentlessConfigurationSource.UfcHttpResponse fetch( - final HttpUrl endpoint, final Config config, final String etag) throws IOException { - calls.incrementAndGet(); - requests.add(new Request(etag)); - if (requestStarted != null) { - requestStarted.countDown(); - } - if (releaseRequest != null) { - await(releaseRequest); - } - final Object response = responses.remove(); - if (response instanceof IOException) { - throw (IOException) response; - } - if (response instanceof RuntimeException) { - throw (RuntimeException) response; - } - return (AgentlessConfigurationSource.UfcHttpResponse) response; - } - - @Override - public void cancel() { - cancelCalls.incrementAndGet(); - if (releaseRequest != null) { - releaseRequest.countDown(); - } - } - - private static void await(final CountDownLatch latch) throws IOException { - try { - if (!latch.await(1, TimeUnit.SECONDS)) { - throw new SocketTimeoutException("test request did not release"); - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException(e); - } - } - } - - private static final class Request { - private final String etag; - - private Request(final String etag) { - this.etag = etag; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java deleted file mode 100644 index 117c72ba2a1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessFeatureFlagBackendApiTest.java +++ /dev/null @@ -1,196 +0,0 @@ -package com.datadog.featureflag; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import datadog.communication.BackendApi; -import datadog.communication.HttpResponseException; -import datadog.communication.http.OkHttpUtils; -import datadog.communication.util.IOThrowingFunction; -import java.io.IOException; -import java.io.InputStream; -import java.net.ConnectException; -import java.net.SocketException; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import okhttp3.MediaType; -import okhttp3.RequestBody; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; - -class AgentlessFeatureFlagBackendApiTest { - - @ParameterizedTest - @ValueSource(ints = {403, 404, 405}) - void replaysRejectedBatchDirectlyAndKeepsDirectRoute(final int statusCode) throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new HttpResponseException(statusCode, "rejected")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi( - local, - () -> { - directApiCreations.incrementAndGet(); - return direct; - }, - "flag evaluation"); - final RequestBody firstBody = requestBody("first"); - final RequestBody secondBody = requestBody("second"); - - assertEquals(0, directApiCreations.get()); - api.post("flagevaluation", firstBody, stream -> null, null, false); - api.post("flagevaluation", secondBody, stream -> null, null, false); - - assertEquals(1, directApiCreations.get()); - assertEquals(1, local.calls); - assertEquals(2, direct.calls); - assertSame(firstBody, local.requestBodies.get(0)); - assertSame(firstBody, direct.requestBodies.get(0)); - assertSame(secondBody, direct.requestBodies.get(1)); - } - - @ParameterizedTest - @MethodSource("featureFlagRoutes") - void fallsBackAfterConnectionRefusal(final String route, final String eventType) - throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new ConnectException("connection refused")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi(local, () -> direct, eventType); - - api.post(route, requestBody(eventType), stream -> null, null, false); - - assertEquals(1, local.calls); - assertEquals(1, direct.calls); - } - - @Test - void doesNotReturnToLocalRouteAfterSwitchingToDirectIntake() throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new ConnectException("connection refused")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi(local, () -> direct, "exposure"); - - api.post("exposures", requestBody("first"), stream -> null, null, false); - direct.failure = new IOException("direct intake failed"); - - assertThrows( - IOException.class, - () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); - assertEquals(1, local.calls); - assertEquals(2, direct.calls); - } - - @ParameterizedTest - @ValueSource(ints = {429, 500}) - void doesNotReplayAmbiguousHttpFailure(final int statusCode) { - assertNoDirectReplay(new HttpResponseException(statusCode, "ambiguous")); - } - - @Test - void doesNotReplayTimeout() { - assertNoDirectReplay(new SocketTimeoutException("timed out")); - } - - @Test - void doesNotReplayConnectionReset() { - assertNoDirectReplay(new SocketException("connection reset")); - } - - @Test - void doesNotRetryDirectApiCreationWhenFallbackIsUnavailable() { - final RecordingBackendApi local = - new RecordingBackendApi(new HttpResponseException(404, "rejected")); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi( - local, - () -> { - directApiCreations.incrementAndGet(); - return null; - }, - "exposure"); - - assertThrows( - HttpResponseException.class, - () -> api.post("exposures", requestBody("first"), stream -> null, null, false)); - assertThrows( - HttpResponseException.class, - () -> api.post("exposures", requestBody("second"), stream -> null, null, false)); - - assertEquals(2, local.calls); - assertEquals(1, directApiCreations.get()); - } - - private static void assertNoDirectReplay(final IOException failure) { - final RecordingBackendApi local = new RecordingBackendApi(failure); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AtomicInteger directApiCreations = new AtomicInteger(); - final AgentlessFeatureFlagBackendApi api = - new AgentlessFeatureFlagBackendApi( - local, - () -> { - directApiCreations.incrementAndGet(); - return direct; - }, - "flag evaluation"); - - assertThrows( - IOException.class, - () -> api.post("flagevaluation", requestBody("evaluation"), stream -> null, null, false)); - - assertEquals(1, local.calls); - assertEquals(0, direct.calls); - assertEquals(0, directApiCreations.get()); - } - - private static RequestBody requestBody(final String value) { - return RequestBody.create(MediaType.parse("application/json"), value); - } - - private static Stream featureFlagRoutes() { - return Stream.of( - Arguments.of("exposures", "exposure"), Arguments.of("flagevaluation", "flag evaluation")); - } - - private static final class RecordingBackendApi implements BackendApi { - private IOException failure; - private final List requestBodies = new ArrayList<>(); - private int calls; - - private RecordingBackendApi() { - this(null); - } - - private RecordingBackendApi(@Nullable final IOException failure) { - this.failure = failure; - } - - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - calls++; - requestBodies.add(requestBody); - if (failure != null) { - throw failure; - } - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/EventProxyTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/EventProxyTest.java new file mode 100644 index 00000000000..c0492e04819 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/EventProxyTest.java @@ -0,0 +1,102 @@ +package com.datadog.featureflag; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; +import datadog.communication.HttpResponseException; +import datadog.communication.http.HttpRetryPolicy; +import datadog.trace.api.intake.Intake; +import java.io.IOException; +import java.net.ConnectException; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class EventProxyTest { + private static final byte[] PAYLOAD = "{}".getBytes(UTF_8); + + @Test + void isUnavailableWithoutAgentProxySupport() throws IOException { + final EventProxy proxy = new EventProxy(mock(BackendApiFactory.class), false); + + assertFalse(proxy.isAvailable()); + assertFalse(proxy.post("exposures", PAYLOAD)); + } + + @Test + void postsEachRouteThroughItsOwnClient() throws IOException { + final BackendApiFactory factory = mock(BackendApiFactory.class); + final BackendApi exposures = mock(BackendApi.class); + final BackendApi flagEvaluations = mock(BackendApi.class); + when(factory.createEvpProxyApi(Intake.EVENT_PLATFORM, true)).thenReturn(exposures); + when(factory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(flagEvaluations); + final EventProxy proxy = new EventProxy(factory, false); + + assertTrue(proxy.isAvailable()); + assertTrue(proxy.post("exposures", PAYLOAD)); + assertTrue(proxy.post("flagevaluation", PAYLOAD)); + + verify(exposures).post(eq("exposures"), any(), any(), any(), eq(false)); + verify(flagEvaluations).post(eq("flagevaluation"), any(), any(), any(), eq(false)); + } + + @Test + void doesNotRetryWhenDirectFallbackIsAvailable() { + final BackendApiFactory factory = mock(BackendApiFactory.class); + final EventProxy proxy = new EventProxy(factory, true); + + proxy.isAvailable(); + + verify(factory) + .createEvpProxyApi(Intake.EVENT_PLATFORM, true, HttpRetryPolicy.Factory.NEVER_RETRY); + verify(factory) + .createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); + } + + @Test + void reportsConnectionFailuresAsUnavailable() throws IOException { + final BackendApi api = proxyApi(new ConnectException("refused")); + + assertFalse(new EventProxy(factoryOf(api), false).post("exposures", PAYLOAD)); + } + + @TableTest({ + "Scenario | Status | Definitive", + "forbidden | 403 | true ", + "not found | 404 | true ", + "not allowed | 405 | true ", + "server error | 500 | false ", + "rate limited | 429 | false " + }) + void classifiesProxyRejections(final int status, final boolean definitive) throws IOException { + final BackendApi api = proxyApi(new HttpResponseException(status, "rejected")); + final EventProxy proxy = new EventProxy(factoryOf(api), false); + + if (definitive) { + assertFalse(proxy.post("exposures", PAYLOAD)); + } else { + assertThrows(HttpResponseException.class, () -> proxy.post("exposures", PAYLOAD)); + } + } + + private static BackendApi proxyApi(final IOException failure) throws IOException { + final BackendApi api = mock(BackendApi.class); + when(api.post(any(), any(), any(), any(), anyBoolean())).thenThrow(failure); + return api; + } + + private static BackendApiFactory factoryOf(final BackendApi api) { + final BackendApiFactory factory = mock(BackendApiFactory.class); + when(factory.createEvpProxyApi(eq(Intake.EVENT_PLATFORM), anyBoolean())).thenReturn(api); + return factory; + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java deleted file mode 100644 index f70a4ff0fa1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ /dev/null @@ -1,546 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static java.util.Collections.singletonList; -import static java.util.Collections.singletonMap; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.IntakeApi; -import datadog.communication.ddagent.DDAgentFeaturesDiscovery; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.agent.test.server.http.JavaTestHttpServer; -import datadog.trace.agent.test.server.http.JavaTestHttpServer.HandlerApi; -import datadog.trace.api.Config; -import datadog.trace.api.IdGenerationStrategy; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.Allocation; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.ExposuresRequest; -import datadog.trace.api.featureflag.exposure.Flag; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.exposure.Variant; -import datadog.trace.api.intake.Intake; -import datadog.trace.test.util.PollingConditions; -import java.io.ByteArrayInputStream; -import java.net.ConnectException; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import java.util.Random; -import java.util.Set; -import java.util.TreeSet; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import okio.Buffer; -import okio.Okio; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.tabletest.junit.TableTest; - -class ExposureWriterTests { - - private static final String EXPOSURES_ENDPOINT = "/evp_proxy/api/v2/exposures"; - private static final String DIRECT_EXPOSURES_ENDPOINT = "/api/v2/exposures"; - private static final String API_KEY = "test-api-key"; - private static final double TIMEOUT_SECONDS = 5; - - private final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); - private Queue requests; - private Set failed; - private JavaTestHttpServer server; - private SharedCommunicationObjects sharedCommunicationObjects; - - @BeforeEach - void setUp() { - requests = new ConcurrentLinkedQueue<>(); - failed = Collections.newSetFromMap(new ConcurrentHashMap()); - JsonAdapter adapter = - new Moshi.Builder().build().adapter(ExposuresRequest.class); - server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> { - h.prefix(EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); - h.prefix( - DIRECT_EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); - })); - sharedCommunicationObjects = sharedCommunicationObjects(true); - } - - @AfterEach - void cleanup() { - if (server != null) { - server.close(); - } - } - - private void handleExposureRequest(HandlerApi api, JsonAdapter adapter) - throws Exception { - ExposuresRequest exposuresRequest = - adapter.fromJson( - Okio.buffer(Okio.source(new ByteArrayInputStream(api.getRequest().getBody())))); - String serviceName = exposuresRequest.context.get("service"); - boolean failForever = "fail-forever".equals(serviceName); - boolean fail = serviceName.startsWith("fail") && (failed.add(serviceName) || failForever); - if (fail) { - api.getResponse().status(500).send("Boom!!!"); - } else { - requests.add(exposuresRequest); - api.getResponse().status(200).send("OK"); - } - } - - @TableTest({ - "service | env | version", - " | | ", - "'test-service' | 'test' | '23' ", - "'test-service' | | '23' ", - "'test-service' | 'test' | " - }) - void testExposureEventWrites(String service, String env, String version) throws Exception { - Config config = mockConfig(service, env, version); - List exposures = buildExposures(5); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - poll.eventually( - () -> { - assertFalse(requests.isEmpty()); - for (ExposuresRequest request : requests) { - assertContext(request.context, service, env, version); - } - assertExposures(allExposures(), exposures); - }); - } - } - - @Test - void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { - Config config = mockConfig("test-service"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - IntakeApi directApi = - new IntakeApi( - HttpUrl.get(server.getAddress()).resolve("/api/v2/"), - API_KEY, - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - new OkHttpClient.Builder().build(), - false); - when(backendApiFactory.createDirectIntakeApi( - eq(datadog.trace.api.intake.Intake.EVENT_PLATFORM), eq(true), eq(false))) - .thenReturn(directApi); - FeatureFlagBackendApiFactory exposureBackendApiFactory = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); - List exposures = buildExposures(5); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, exposureBackendApiFactory, config)) { - writer.init(); - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - poll.eventually( - () -> { - assertEquals(DIRECT_EXPOSURES_ENDPOINT, server.getLastRequest().getPath()); - assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); - assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); - assertExposures(allExposures(), exposures); - }); - } - } - - @Test - void testLruCache() throws Exception { - Config config = mockConfig("test-service"); - List exposures = buildExposures(6); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - // populating the cache - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - // all events are written - poll.eventually(() -> assertEquals(exposures.size(), allExposures().size())); - - // publishing duplicate events - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - // no events are written - MILLISECONDS.sleep(300); // wait until a flush happens - assertEquals(exposures.size(), allExposures().size()); - - // a new event is generated - writer.accept(buildExposure()); - - // oldest event is evicted and the new one is submitted - poll.eventually(() -> assertEquals(exposures.size() + 1, allExposures().size())); - } - } - - @Test - void testHighLoadScenario() throws Exception { - Config config = mockConfig("test-service"); - int exposuresPerThread = 100; - Random random = new Random(); - int threads = Runtime.getRuntime().availableProcessors(); - ExecutorService executor = Executors.newFixedThreadPool(threads); - List exposures = buildExposures(threads * exposuresPerThread); - CountDownLatch latch = new CountDownLatch(1); - - try (ExposureWriterImpl writer = new ExposureWriterImpl(sharedCommunicationObjects, config)) { - writer.init(); - List> futures = new ArrayList<>(); - for (int index = 0; index < exposures.size(); index += exposuresPerThread) { - List partition = - exposures.subList(index, Math.min(index + exposuresPerThread, exposures.size())); - futures.add( - executor.submit( - () -> { - latch.await(); - for (ExposureEvent exposure : partition) { - MILLISECONDS.sleep(random.nextInt(2)); - writer.accept(exposure); - } - return true; - })); - } - latch.countDown(); // start threads - - for (Future future : futures) { - assertTrue(future.get()); // wait for all threads to finish - } - poll.eventually(() -> assertExposures(allExposures(), exposures)); - } finally { - executor.shutdownNow(); - } - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void testFailuresAreRetried(boolean finallyFail) throws Exception { - String serviceName = finallyFail ? "fail-forever" : "fail-once"; - Config config = mockConfig(serviceName); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - writer.accept(buildExposure()); - - MILLISECONDS.sleep(500); // wait for a flush to happen - ExposuresRequest found = findRequest(serviceName); - if (finallyFail) { - assertNull(found, requests.toString()); - } else { - poll.eventually(() -> assertNotNull(findRequest(serviceName), requests.toString())); - } - } - } - - @Test - void testSerializationFailureDoesNotPoisonFollowingExposures() throws Exception { - Config config = mockConfig("test-service"); - ExposureEvent validExposure = buildExposure(); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, sharedCommunicationObjects, config)) { - writer.init(); - writer.accept(buildExposure(singletonMap("invalid", (Object) Double.NaN))); - - MILLISECONDS.sleep(300); // wait for the invalid batch to be dropped - writer.accept(validExposure); - - poll.eventually(() -> assertExposures(allExposures(), singletonList(validExposure))); - } - } - - @Test - void testAmbiguousExposureBatchIsNotRetriedOrReplayedDirectly() throws Exception { - final Config config = mockConfig("test-service"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, true, HttpRetryPolicy.Factory.NEVER_RETRY)) - .thenReturn(proxyApi); - when(backendApiFactory.createDirectIntakeApi(eq(Intake.EVENT_PLATFORM), eq(true), eq(false))) - .thenReturn(directApi); - when(proxyApi.post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))) - .thenThrow(new SocketTimeoutException("ambiguous timeout")) - .thenThrow(new ConnectException("definitive refusal")); - final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FeatureFlagEventType.EXPOSURE); - final List exposures = buildExposures(2); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, featureFlagBackendApiFactory, config)) { - writer.init(); - writer.accept(exposures.get(0)); - - poll.eventually( - () -> - verify(proxyApi) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))); - MILLISECONDS.sleep(300); - verify(proxyApi, times(1)) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); - verify(directApi, never()) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false)); - - writer.accept(exposures.get(1)); - poll.eventually( - () -> - verify(directApi) - .post(eq("exposures"), any(RequestBody.class), any(), any(), eq(false))); - - final ArgumentCaptor directBody = ArgumentCaptor.forClass(RequestBody.class); - verify(directApi).post(eq("exposures"), directBody.capture(), any(), any(), eq(false)); - final Buffer buffer = new Buffer(); - directBody.getValue().writeTo(buffer); - final ExposuresRequest directRequest = - new Moshi.Builder().build().adapter(ExposuresRequest.class).fromJson(buffer.readUtf8()); - assertNotNull(directRequest); - assertExposures(directRequest.exposures, singletonList(exposures.get(1))); - } - } - - @Test - void testWriterStopsReceivingExposuresIfEvpProxyIsNotAvailable() throws Exception { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(false); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(sharedCommunicationObjects, Config.get())) { - writer.init(); - poll.eventually(() -> assertFalse(writer.isSerializerThreadAlive())); - - FeatureFlaggingGateway.dispatch(buildExposure()); - - assertEquals(0, writer.queueSize()); - } - } - - private static Config mockConfig(String serviceName) { - return mockConfig(serviceName, "test", "0.0.0"); - } - - private static Config mockConfig(String serviceName, String env, String version) { - Config config = mock(Config.class); - when(config.getIdGenerationStrategy()).thenReturn(IdGenerationStrategy.fromName("RANDOM")); - when(config.getServiceName()).thenReturn(serviceName); - when(config.getEnv()).thenReturn(env); - when(config.getVersion()).thenReturn(version); - return config; - } - - private SharedCommunicationObjects sharedCommunicationObjects(boolean evpProxyAvailable) { - DDAgentFeaturesDiscovery discovery = mock(DDAgentFeaturesDiscovery.class); - when(discovery.supportsEvpProxy()).thenReturn(evpProxyAvailable); - if (evpProxyAvailable) { - when(discovery.getEvpProxyEndpoint()).thenReturn("/evp_proxy/"); - } - - SharedCommunicationObjects sharedCommunicationObjects = new SharedCommunicationObjects(); - sharedCommunicationObjects.setFeaturesDiscovery(discovery); - sharedCommunicationObjects.agentUrl = HttpUrl.get(server.getAddress()); - sharedCommunicationObjects.agentHttpClient = new OkHttpClient.Builder().build(); - return sharedCommunicationObjects; - } - - private static void assertContext( - Map context, String service, String env, String version) { - assertEquals(service == null ? "unknown" : service, context.get("service")); - assertOptionalContextValue(context, "env", env); - assertOptionalContextValue(context, "version", version); - } - - private static void assertOptionalContextValue( - Map context, String key, String value) { - if (value == null) { - assertFalse(context.containsKey(key)); - } else { - assertEquals(value, context.get(key)); - } - } - - private ExposuresRequest findRequest(String serviceName) { - for (ExposuresRequest request : requests) { - if (serviceName.equals(request.context.get("service"))) { - return request; - } - } - return null; - } - - private List allExposures() { - List exposures = new ArrayList<>(); - for (ExposuresRequest request : requests) { - exposures.addAll(request.exposures); - } - return exposures; - } - - private static void assertExposures( - List receivedExposures, List expectedExposures) { - assertEquals(expectedExposures.size(), receivedExposures.size()); - TreeSet received = new TreeSet<>(ExposureWriterTests::compare); - received.addAll(receivedExposures); - assertTrue(received.containsAll(expectedExposures)); - } - - private static int compare(ExposureEvent first, ExposureEvent second) { - if (first == second) { - return 0; - } - if (first == null) { - return -1; - } - if (second == null) { - return 1; - } - - int result = Long.compare(first.timestamp, second.timestamp); - if (result != 0) { - return result; - } - - result = compareNullableString(first.flag == null ? null : first.flag.key, second.flag); - if (result != 0) { - return result; - } - - result = - compareNullableString(first.variant == null ? null : first.variant.key, second.variant); - if (result != 0) { - return result; - } - - result = - compareNullableString( - first.allocation == null ? null : first.allocation.key, second.allocation); - if (result != 0) { - return result; - } - - result = compareNullableString(first.subject == null ? null : first.subject.id, second.subject); - if (result != 0) { - return result; - } - - Map.Entry firstEntry = firstEntry(first.subject); - Map.Entry secondEntry = firstEntry(second.subject); - result = - compareNullableString( - firstEntry == null ? null : firstEntry.getKey(), - secondEntry == null ? null : secondEntry.getKey()); - if (result != 0) { - return result; - } - return compareNullableString( - firstEntry == null ? null : String.valueOf(firstEntry.getValue()), - secondEntry == null ? null : String.valueOf(secondEntry.getValue())); - } - - private static int compareNullableString(String first, Flag second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Variant second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Allocation second) { - return compareNullableString(first, second == null ? null : second.key); - } - - private static int compareNullableString(String first, Subject second) { - return compareNullableString(first, second == null ? null : second.id); - } - - private static int compareNullableString(String first, String second) { - String firstValue = first == null ? "" : first; - String secondValue = second == null ? "" : second; - return firstValue.compareTo(secondValue); - } - - private static Map.Entry firstEntry(Subject subject) { - if (subject == null || subject.attributes == null) { - return null; - } - Iterator> iterator = subject.attributes.entrySet().iterator(); - return iterator.hasNext() ? iterator.next() : null; - } - - private static List buildExposures(int count) { - List exposures = new ArrayList<>(); - for (int index = 0; index < count; index++) { - exposures.add(buildExposure()); - } - return exposures; - } - - private static ExposureEvent buildExposure() { - String id = UUID.randomUUID().toString(); - return buildExposure(id, singletonMap("key_" + id, (Object) ("value_" + id))); - } - - private static ExposureEvent buildExposure(Map attributes) { - return buildExposure(UUID.randomUUID().toString(), attributes); - } - - private static ExposureEvent buildExposure(String id, Map attributes) { - return new ExposureEvent( - System.currentTimeMillis(), - new Allocation("Allocation_" + id), - new Flag("Flag_" + id), - new Variant("Variant_" + id), - new Subject("Subject_" + id, attributes)); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java deleted file mode 100644 index b89cf3ce2b1..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagBackendApiFactoryTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.datadog.featureflag; - -import static com.datadog.featureflag.FeatureFlagEventType.EXPOSURE; -import static com.datadog.featureflag.FeatureFlagEventType.FLAG_EVALUATION; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -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.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import org.junit.jupiter.api.Test; - -class FeatureFlagBackendApiFactoryTest { - - @Test - void remoteConfigUsesOnlyLocalEvpProxy() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(proxyApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void remoteConfigDisablesDeliveryWhenLocalEvpProxyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); - - assertNull(selected); - verify(backendApiFactory).createEvpProxyApi(Intake.EVENT_PLATFORM, true); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); - } - - @Test - void agentlessPrefersLocalEvpProxyWithDirectFallback() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) - .thenReturn(mock(BackendApi.class)); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenReturn(mock(BackendApi.class)); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - verify(backendApiFactory) - .createEvpProxyApi(Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenReturn(directApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(directApi, selected); - } - - @Test - void agentlessUsesLocalEvpProxyWhenApiKeyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM, false)).thenReturn(proxyApi); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertSame(proxyApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessDisablesDeliveryWhenNoRouteIsAvailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertNull(selected); - } - - @Test - void agentlessDisablesDeliveryWhenApiKeyIsEmpty() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, ""); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, EXPOSURE).create(); - - assertNull(selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, true, false); - } - - @Test - void agentlessDoesNotValidateDirectUrlWhileLocalRouteIsAvailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi proxyApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, false, HttpRetryPolicy.Factory.NEVER_RETRY)) - .thenReturn(proxyApi); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenThrow(new IllegalArgumentException("invalid URL")); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertInstanceOf(AgentlessFeatureFlagBackendApi.class, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false); - } - - @Test - void agentlessDisablesDeliveryWhenDirectUrlIsInvalidAndLocalRouteIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM, false, false)) - .thenThrow(new IllegalArgumentException("invalid URL")); - - final BackendApi selected = - new FeatureFlagBackendApiFactory(config, backendApiFactory, FLAG_EVALUATION).create(); - - assertNull(selected); - } - - private static Config config(final String source, final String apiKey) { - final Config config = mock(Config.class); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); - when(config.getApiKey()).thenReturn(apiKey); - return config; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java deleted file mode 100644 index 711e1b90e29..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagEvpPublisherTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.datadog.featureflag; - -import static java.util.Collections.emptyMap; -import static java.util.Collections.singletonList; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.trace.api.featureflag.exposure.Allocation; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.ExposuresRequest; -import datadog.trace.api.featureflag.exposure.Flag; -import datadog.trace.api.featureflag.exposure.Subject; -import datadog.trace.api.featureflag.exposure.Variant; -import datadog.trace.api.intake.Intake; -import java.nio.charset.StandardCharsets; -import okhttp3.RequestBody; -import org.junit.jupiter.api.Test; - -class FeatureFlagEvpPublisherTest { - - @Test - void defaultPublisherRequestsResponseCompression() { - final BackendApi backendApi = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(Intake.EVENT_PLATFORM, true)).thenReturn(backendApi); - - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class); - - publisher.start(); - - verify(factory).createBackendApi(Intake.EVENT_PLATFORM, true); - verifyNoMoreInteractions(factory); - } - - @Test - void responseCompressionCanBeDisabled() throws Exception { - final BackendApi backendApi = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(Intake.EVENT_PLATFORM, false)).thenReturn(backendApi); - - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class, false); - - publisher.post("flagevaluation", new TestRequest("value")); - - verify(factory).createBackendApi(Intake.EVENT_PLATFORM, false); - verify(backendApi) - .post(eq("flagevaluation"), any(RequestBody.class), any(), isNull(), eq(false)); - } - - @Test - void postThrowsWhenEvpBackendApiCannotBeCreated() { - final BackendApiFactory factory = mock(BackendApiFactory.class); - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(factory, TestRequest.class); - - assertFalse(publisher.start()); - assertThrows( - IllegalStateException.class, - () -> publisher.post("flagevaluation", FeatureFlagEvpPublisher.utf8Bytes("{}"))); - } - - @Test - void serializesSerialIdUnderTheIntakeWireKey() { - assertTrue(exposureJson(340132).contains("\"serial_id\":340132")); - } - - @Test - void serializesSerialIdZeroRatherThanOmittingIt() { - assertTrue(exposureJson(0).contains("\"serial_id\":0")); - } - - @Test - void omitsSerialIdKeyWhenAbsent() { - assertFalse(exposureJson(null).contains("serial_id")); - } - - @Test - void omitsSerialIdKeyForAnEventBuiltWithoutOne() { - final ExposureEvent event = - new ExposureEvent( - 1234L, - new Allocation("allocation"), - new Flag("flag"), - new Variant("variant"), - new Subject("subject", emptyMap())); - - assertFalse(exposureJsonOf(event).contains("serial_id")); - } - - private static String exposureJson(final Integer serialId) { - return exposureJsonOf( - new ExposureEvent( - 1234L, - new Allocation("allocation"), - new Flag("flag"), - new Variant("variant"), - new Subject("subject", emptyMap()), - serialId)); - } - - private static String exposureJsonOf(final ExposureEvent event) { - final FeatureFlagEvpPublisher publisher = - new FeatureFlagEvpPublisher<>(mock(BackendApiFactory.class), ExposuresRequest.class); - return new String( - publisher.serialize(new ExposuresRequest(emptyMap(), singletonList(event))), - StandardCharsets.UTF_8); - } - - static class TestRequest { - public final String value; - - TestRequest(final String value) { - this.value = value; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java new file mode 100644 index 00000000000..a1aacefc85d --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java @@ -0,0 +1,113 @@ +package com.datadog.featureflag; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.remoteconfig.ConfigurationPoller; +import datadog.remoteconfig.Product; +import datadog.trace.api.Config; +import datadog.trace.api.telemetry.CoreMetricCollector; +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import java.util.Properties; +import org.junit.jupiter.api.Test; + +class FeatureFlagsBackendTest { + private final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + private final Config config = mock(Config.class); + private final EventProxy eventProxy = mock(EventProxy.class); + private final AgentSpan root = rootSpan(); + // SpanEnrichmentWriter is final, so the test drives a real one bound to a mocked root span. + private final SpanEnrichmentWriter spanEnrichmentWriter = + new SpanEnrichmentWriter(() -> this.root); + private final CoreMetricCollector metrics = mock(CoreMetricCollector.class); + private final FeatureFlagsBackend backend = + new FeatureFlagsBackend( + this.sco, this.config, this.eventProxy, this.spanEnrichmentWriter, this.metrics); + + private static AgentSpan rootSpan() { + final AgentSpan root = mock(AgentSpan.class); + when(root.getLocalRootSpan()).thenReturn(root); + return root; + } + + @Test + void resolvesSettingsWithAgentDefaultsFromTheAgentConfiguration() { + when(this.config.getApiKey()).thenReturn("key"); + when(this.config.getSite()).thenReturn("datadoghq.eu"); + when(this.config.getEnv()).thenReturn("prod"); + when(this.config.getServiceName()).thenReturn("service"); + when(this.config.getVersion()).thenReturn("1.0"); + final Properties properties = new Properties(); + properties.setProperty("feature.flags.configuration.source", "agentless"); + when(this.config.configProvider()) + .thenReturn(ConfigProvider.withPropertiesOverride(properties)); + + assertEquals("key", this.backend.setting("api-key")); + assertEquals("datadoghq.eu", this.backend.setting("site")); + assertEquals("prod", this.backend.setting("env")); + assertEquals("service", this.backend.setting("service")); + assertEquals("1.0", this.backend.setting("version")); + assertEquals("agentless", this.backend.setting("feature.flags.configuration.source")); + } + + @Test + void sharesOneRemoteConfigServiceAcrossSubscriptions() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + when(this.sco.configurationPoller(this.config)).thenReturn(poller); + + this.backend.subscribeRemoteConfig(content -> {}); + this.backend.subscribeRemoteConfig(content -> {}); + + verify(this.sco, times(1)).configurationPoller(this.config); + verify(poller, times(1)).addCapabilities(anyLong()); + } + + @Test + void failsToSubscribeWithoutRemoteConfig() { + assertThrows( + IllegalStateException.class, () -> this.backend.subscribeRemoteConfig(content -> {})); + } + + @Test + void closeUnsubscribesRemoteConfigAndClearsSpanEnrichment() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + when(this.sco.configurationPoller(this.config)).thenReturn(poller); + this.backend.subscribeRemoteConfig(content -> {}); + this.backend.enrichSerialId(1, false, null); + + this.backend.close(); + + verify(poller).removeListeners(Product.FFE_FLAGS); + assertTrue(this.spanEnrichmentWriter.states().isEmpty()); + } + + @Test + void delegatesEventsMetricsAndSpanEnrichment() throws Exception { + final byte[] payload = "{}".getBytes(UTF_8); + when(this.eventProxy.isAvailable()).thenReturn(true); + when(this.eventProxy.post("exposures", payload)).thenReturn(true); + + assertTrue(this.backend.isEventProxyAvailable()); + assertTrue(this.backend.postEvent("exposures", payload)); + this.backend.count("flagevaluation.rows.dropped", 3, "closed"); + this.backend.count("flagevaluation.payload.splits", 1, null); + this.backend.enrichSerialId(42, true, "user"); + this.backend.enrichRuntimeDefault("flag", "default"); + + verify(this.metrics).count("flagevaluation.rows.dropped", 3, "reason:closed"); + verify(this.metrics).count("flagevaluation.payload.splits", 1, null); + final SpanEnrichmentAccumulator state = this.spanEnrichmentWriter.states().peek(this.root); + assertNotNull(state); + assertTrue(state.hasData()); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java deleted file mode 100644 index 502f0e76c69..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationTestSupport.java +++ /dev/null @@ -1,246 +0,0 @@ -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.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.intake.Intake; -import datadog.trace.api.telemetry.CoreMetricCollector; -import datadog.trace.api.telemetry.MetricCollector; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; -import okhttp3.RequestBody; -import okio.Buffer; - -final class FlagEvaluationTestSupport { - - static final long REALISTIC_EVAL_MS = 1_760_000_000_000L; - static final JsonAdapter> JSON_MAP; - - static { - final Moshi moshi = new Moshi.Builder().build(); - final Type type = Types.newParameterizedType(Map.class, String.class, Object.class); - JSON_MAP = moshi.adapter(type); - } - - private FlagEvaluationTestSupport() {} - - static Supplier backendApiSupplier(final BackendApiFactory factory) { - return () -> factory.createBackendApi(Intake.EVENT_PLATFORM, false); - } - - static void clearCoreMetrics() { - CoreMetricCollector.getInstance().drain(); - } - - static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final Map attrs) { - return new FlagEvalEvent(flagKey, variant, allocationKey, targetingKey, evalTimeMs, attrs); - } - - static FlagEvalEvent event( - final String flagKey, - final String variant, - final String allocationKey, - final String targetingKey, - final long evalTimeMs, - final boolean observeFullEvaluationData, - final Map attrs) { - return new FlagEvalEvent( - flagKey, - variant, - allocationKey, - targetingKey, - null, - evalTimeMs, - observeFullEvaluationData, - attrs); - } - - static FlagEvalEvent errorEvent( - final String flagKey, final String errorMessage, final long evalTimeMs) { - return new FlagEvalEvent( - flagKey, null, null, null, errorMessage, evalTimeMs, java.util.Collections.emptyMap()); - } - - static FlagEvalEvent simpleEvent(final String flagKey, final String variant) { - return event(flagKey, variant, "alloc1", "user-1", 1000L, java.util.Collections.emptyMap()); - } - - static String repeat(final char c, final int count) { - final char[] chars = new char[count]; - Arrays.fill(chars, c); - return new String(chars); - } - - static TestWriterSetup buildTestWriter(final BackendApi mockEvp) { - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - - final Map context = new HashMap<>(); - context.put("service", "test-service"); - - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest(backendApiSupplier(factory), context); - - return new TestWriterSetup(handler, mockEvp, factory); - } - - static TestWriterSetup buildTestWriter( - final BackendApi mockEvp, final int payloadSizeLimitBytes) { - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - - final Map context = new HashMap<>(); - context.put("service", "test-service"); - - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest( - backendApiSupplier(factory), context, payloadSizeLimitBytes); - - return new TestWriterSetup(handler, mockEvp, factory); - } - - static CapturedJson flushAndCapture(final TestWriterSetup setup) throws Exception { - final List captured = flushAndCaptureAll(setup); - assertEquals(1, captured.size(), "Expected exactly one posted payload"); - return captured.get(0); - } - - static List flushAndCaptureAll(final TestWriterSetup setup) throws Exception { - final List captured = new ArrayList<>(); - when(setup.mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - final List json = new ArrayList<>(); - for (final RequestBody body : captured) { - json.add(readJson(body)); - } - return json; - } - - static CapturedJson readJson(final RequestBody body) throws Exception { - assertNotNull(body, "RequestBody must have been posted"); - final Buffer buf = new Buffer(); - body.writeTo(buf); - final String raw = buf.readUtf8(); - return new CapturedJson(raw, JSON_MAP.fromJson(raw)); - } - - static Map flushAndCaptureJson(final TestWriterSetup setup) throws Exception { - return flushAndCapture(setup).parsed; - } - - static long metricSum( - final Collection metrics, - final String metricName, - final String tag) { - long sum = 0; - for (final MetricCollector.Metric metric : metrics) { - if (!metricName.equals(metric.metricName)) { - continue; - } - if (tag == null) { - if (!metric.tags.isEmpty()) { - continue; - } - } else if (!metric.tags.contains(tag)) { - continue; - } - sum += metric.value.longValue(); - } - return sum; - } - - static datadog.trace.api.Config cfg() { - final datadog.trace.api.Config config = mock(datadog.trace.api.Config.class); - when(config.getServiceName()).thenReturn("test-service"); - return config; - } - - static void assertObjectWithKey(final Object o, final String expectedKey, final String msg) { - assertTrue(o instanceof Map, msg + " (must be a JSON object, not a bare string)"); - assertEquals(expectedKey, ((Map) o).get("key"), msg); - } - - @SuppressWarnings("unchecked") - static Map firstEvent(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events, "flagEvaluations array must be present"); - assertFalse(events.isEmpty(), "flagEvaluations must not be empty"); - return (Map) events.get(0); - } - - @SuppressWarnings("unchecked") - static int eventCount(final Map batch) { - final List events = (List) batch.get("flagEvaluations"); - assertNotNull(events, "flagEvaluations array must be present"); - return events.size(); - } - - @SuppressWarnings("unchecked") - static Map eventForFlag(final Map batch, final String flagKey) { - final List events = (List) batch.get("flagEvaluations"); - for (final Object o : events) { - final Map ev = (Map) o; - final Map flag = (Map) ev.get("flag"); - if (flag != null && flagKey.equals(flag.get("key"))) { - return ev; - } - } - return null; - } - - static class TestWriterSetup { - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler; - final BackendApi mockEvp; - final BackendApiFactory factory; - - TestWriterSetup( - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler, - final BackendApi mockEvp, - final BackendApiFactory factory) { - this.handler = handler; - this.mockEvp = mockEvp; - this.factory = factory; - } - } - - static class CapturedJson { - final String raw; - final Map parsed; - - CapturedJson(final String raw, final Map parsed) { - this.raw = raw; - this.parsed = parsed; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java deleted file mode 100644 index 75a767c5c3f..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java +++ /dev/null @@ -1,861 +0,0 @@ -package com.datadog.featureflag; - -import static com.datadog.featureflag.FlagEvaluationTestSupport.JSON_MAP; -import static com.datadog.featureflag.FlagEvaluationTestSupport.backendApiSupplier; -import static com.datadog.featureflag.FlagEvaluationTestSupport.buildTestWriter; -import static com.datadog.featureflag.FlagEvaluationTestSupport.cfg; -import static com.datadog.featureflag.FlagEvaluationTestSupport.clearCoreMetrics; -import static com.datadog.featureflag.FlagEvaluationTestSupport.event; -import static com.datadog.featureflag.FlagEvaluationTestSupport.eventForFlag; -import static com.datadog.featureflag.FlagEvaluationTestSupport.flushAndCapture; -import static com.datadog.featureflag.FlagEvaluationTestSupport.flushAndCaptureJson; -import static com.datadog.featureflag.FlagEvaluationTestSupport.metricSum; -import static com.datadog.featureflag.FlagEvaluationTestSupport.repeat; -import static com.datadog.featureflag.FlagEvaluationTestSupport.simpleEvent; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static java.util.Collections.emptyMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import datadog.common.queue.MessagePassingBlockingQueue; -import datadog.common.queue.Queues; -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.IntakeApi; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.agent.test.server.http.JavaTestHttpServer; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import datadog.trace.api.intake.Intake; -import datadog.trace.api.telemetry.CoreMetricCollector; -import datadog.trace.api.telemetry.MetricCollector; -import datadog.trace.test.util.PollingConditions; -import java.io.IOException; -import java.lang.reflect.Field; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import okio.Buffer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class FlagEvaluationWriterImplTest { - - private static final String DIRECT_FLAG_EVALUATION_ENDPOINT = "/api/v2/flagevaluation"; - private static final String API_KEY = "test-api-key"; - private static final double TIMEOUT_SECONDS = 5; - - @BeforeEach - void clearCoreMetricsBefore() { - clearCoreMetrics(); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - } - - @AfterEach - void clearCoreMetricsAfter() { - clearCoreMetrics(); - FeatureFlaggingGateway.setFlagEvalWriter(null); - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(true); - // Reset the dispatched UFC state so observeFullEvaluationData can't leak into other tests. - FeatureFlaggingGateway.dispatch((ServerConfiguration) null); - } - - @Test - void degradedCapOverflowTelemetryIsEmittedOnFlush() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.addDroppedDegradedOverflowForTest(3); - setup.handler.flush(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 3, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_DEGRADED_CAP)); - } - - @Test - void startRegistersWriterAndCloseDeregistersIt() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.start(); - assertEquals(writer, FeatureFlaggingGateway.getFlagEvalWriter()); - - writer.close(); - writer.close(); - writer.start(); - - assertNull(FeatureFlaggingGateway.getFlagEvalWriter()); - } - - @Test - void queueOverflowIncrementsObservableDropCounter() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl(2, 10L, TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - for (int i = 0; i < 100; i++) { - writer.enqueue(simpleEvent("of-flag", "on")); - } - - assertTrue(writer.droppedQueueOverflow() > 0); - final long queueDrops = writer.droppedQueueOverflow(); - writer.flushForTest(); - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - queueDrops, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_QUEUE_OVERFLOW)); - } - - @Test - void enqueueAfterCloseIsDroppedAndCounted() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.close(); - writer.enqueue(simpleEvent("closed-flag", "on")); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void enqueueDisabledDropsAndCountsAsClosedDrop() { - // FeatureFlaggingSystem.stop() flips the gate before this writer's close() runs. Producers - // that race the gate flip must count the drop, otherwise shutdown loss stays invisible. - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - FeatureFlaggingGateway.setFlagEvaluationEnqueueEnabled(false); - writer.enqueue(simpleEvent("disabled-flag", "on")); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void closeSweepsAndCountsEventsLeftInTheQueue() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - // The worker is never started, so nothing drains these; close() must account for them rather - // than leave them silently stranded. Stands in for the narrow window where a lock-free - // producer offers after the worker's final drain. - writer.enqueue(simpleEvent("residual-flag-1", "on")); - writer.enqueue(simpleEvent("residual-flag-2", "on")); - writer.close(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 2, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_CLOSED)); - assertNull(writer.pollQueuedEventForTest()); - } - - @Test - void enqueueIgnoresNullEvent() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.enqueue(null); - - assertNull(writer.pollQueuedEventForTest()); - assertEquals(0, writer.droppedQueueOverflow()); - } - - @Test - void enqueueDoesNotAggregateOnTheCallingThread() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.enqueue(simpleEvent("g2-flag", "on")); - writer.enqueue(simpleEvent("g2-flag", "on")); - - assertEquals(0, writer.aggregatorFullTierSizeForTest()); - assertEquals(0, writer.droppedQueueOverflow()); - } - - @Test - void handlerRunFailsFastWhenEvpProxyIsUnavailable() { - final BackendApiFactory factory = mock(BackendApiFactory.class); - final FlagEvaluationWriterImpl.SerializingHandlerForTest handler = - FlagEvaluationWriterImpl.createHandlerForTest(backendApiSupplier(factory), context()); - - assertThrows(IllegalArgumentException.class, handler::run); - } - - @Test - void flushIfNecessarySkipsEmptyStateAndWaitsForInterval() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.flushIfNecessary(); - setup.handler.add(simpleEvent("pending-flag", "on")); - setup.handler.drainAndAggregate(); - setup.handler.flushIfNecessary(); - - assertEquals(1, setup.handler.fullTierSizeForTest()); - } - - @Test - void flushIfNecessaryDoesNotReturnEarlyWhenOnlyQueueDropsArePending() { - final AtomicLong queueDrops = new AtomicLong(1); - final FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler handler = - new FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler( - () -> null, - Queues.mpscBlockingConsumerArrayQueue(16), - Long.MAX_VALUE, - TimeUnit.NANOSECONDS, - context(), - queueDrops, - new java.util.concurrent.ConcurrentHashMap<>(), - () -> {}, - FlagEvaluationWriterImpl.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - - handler.flushIfNecessary(); - - assertEquals(1, queueDrops.get()); - } - - @Test - @SuppressWarnings("unchecked") - void workerHandlesEmptyPolls() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final MessagePassingBlockingQueue queue = - mock(MessagePassingBlockingQueue.class); - when(queue.poll(100, TimeUnit.MILLISECONDS)) - .thenAnswer( - invocation -> { - Thread.currentThread().interrupt(); - return null; - }); - final FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler handler = - new FlagEvaluationWriterImpl.FlagEvaluationSerializingHandler( - backendApiSupplier(factory), - queue, - Long.MAX_VALUE, - TimeUnit.NANOSECONDS, - context(), - new AtomicLong(0), - new java.util.concurrent.ConcurrentHashMap<>(), - () -> {}, - FlagEvaluationWriterImpl.FLAG_EVALUATION_PAYLOAD_SIZE_LIMIT_BYTES); - - handler.run(); - - assertTrue(Thread.interrupted()); - } - - @Test - void degradedBucketsAreSerializedWithoutTargetingKeyOrContext() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.addDegradedBucketForTest("degraded-flag", "on", "alloc1", null, 1000L); - - final Map json = flushAndCaptureJson(setup); - - final Map ev = eventForFlag(json, "degraded-flag"); - assertNotNull(ev); - assertNull(ev.get("targeting_key")); - assertNull(ev.get("context")); - } - - @Test - void testHandlerCanSimulateAndClearDegradedTierAtCap() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.simulateDegradedTierAtCap(); - setup.handler.clearAggregationForTest(); - setup.handler.add(simpleEvent("after-clear", "on")); - setup.handler.drainAndAggregate(); - - assertEquals(1, setup.handler.fullTierSizeForTest()); - } - - @Test - void payloadLimitDropsAreCountedOnFlush() { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp, 128); - setup.handler.add(event(repeat('f', 512), "on", "alloc1", "user-1", 1000L, emptyMap())); - - setup.handler.drainAndAggregate(); - setup.handler.flush(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_PAYLOAD_LIMIT)); - } - - @Test - void finalFlushRunsWithoutTheInterruptFlagSet() throws Exception { - // close() interrupts the worker to break it out of poll(). The final flush does socket I/O, - // and OkHttp fails fast on an interrupted thread, so the flag must be clear by the time the - // publisher is called. A mock publisher ignores the flag, so assert on it directly. - final java.util.concurrent.CountDownLatch posted = new java.util.concurrent.CountDownLatch(1); - final boolean[] interruptedDuringPost = {true}; - final BackendApi mockEvp = mock(BackendApi.class); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - interruptedDuringPost[0] = Thread.currentThread().isInterrupted(); - posted.countDown(); - return null; - }); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - writer.enqueue(simpleEvent("interrupt-flag", "on")); - writer.close(); - - assertTrue(posted.await(5, TimeUnit.SECONDS)); - assertFalse( - interruptedDuringPost[0], - "final flush must not run on an interrupted thread; OkHttp fails fast and the drained" - + " rows are lost without being counted"); - } - - @Test - void closeDrainsAndFinalFlushesQueuedEvents() throws Exception { - final java.util.concurrent.CountDownLatch posted = new java.util.concurrent.CountDownLatch(1); - final RequestBody[] captured = {null}; - final BackendApi mockEvp = mock(BackendApi.class); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured[0] = inv.getArgument(1); - posted.countDown(); - return null; - }); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 64, TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - writer.enqueue(simpleEvent("shutdown-flag", "on")); - writer.close(); - - assertTrue(posted.await(5, TimeUnit.SECONDS)); - assertNotNull(captured[0]); - final Buffer buf = new Buffer(); - captured[0].writeTo(buf); - final Map json = JSON_MAP.fromJson(buf.readUtf8()); - assertNotNull(eventForFlag(json, "shutdown-flag")); - } - - @Test - void continuousTrafficFlushesWithoutWaitingForIdle() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 1 << 12, 1, TimeUnit.MILLISECONDS, backendApiSupplier(factory), cfg()); - - writer.startForTest(); - boolean posted = false; - try { - final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (System.nanoTime() < deadline) { - writer.enqueue(simpleEvent("busy-flag", "on")); - try { - verify(mockEvp, atLeastOnce()) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - posted = true; - break; - } catch (AssertionError ignored) { - // Keep the worker busy until the deadline. - } - } - } finally { - writer.close(); - } - - assertTrue(posted); - } - - @Test - void flushPostsToFlagevaluationEndpoint() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - setup.handler.add(event("flag-f", "on", "alloc1", "user-1", 1000L, emptyMap())); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - - verify(setup.factory).createBackendApi(Intake.EVENT_PLATFORM, false); - verify(mockEvp).post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - } - - @Test - void splitPostFailureDoesNotRetryAlreadySentPayloads() throws Exception { - final int limit = 1_100; - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp, limit); - final AtomicInteger posts = new AtomicInteger(); - doAnswer( - invocation -> { - if (posts.incrementAndGet() == 2) { - throw new IOException("boom"); - } - return null; - }) - .when(mockEvp) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - - for (int i = 0; i < 4; i++) { - final Map attrs = new HashMap<>(); - attrs.put("payload", repeat('x', 180)); - setup.handler.add(event("split-failure-" + i, "on", "alloc1", "user-" + i, 1000L, attrs)); - } - - setup.handler.drainAndAggregate(); - setup.handler.flush(); - assertEquals(2, posts.get()); - setup.handler.flush(); - assertEquals(2, posts.get()); - } - - private static final String HASHED_JANE_DOE = - "sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b"; - - @Test - void observeFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() throws Exception { - // Consent travels on the event (snapshotted by the hook at evaluation time); the writer honours - // it verbatim and never consults the gateway. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(true)); - - final Map json = flushAndCapture(setup).parsed; - - final Map ev = eventForFlag(json, "pii-flag"); - assertNotNull(ev); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - final Map evalAttrs = (Map) ctx.get("evaluation"); - assertNotNull(evalAttrs); - assertEquals("us-east-1", evalAttrs.get("region")); - } - - @Test - void observeFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext() throws Exception { - assertHashedTargetingKeyAndOmittedContext(piiEvent(false)); - } - - @Test - void flagEvalEventDefaultConsentHashesTargetingKeyAndOmitsContext() throws Exception { - // An event built without an explicit consent value defaults to the privacy-preserving false, so - // it must behave exactly like the explicit "false" case. This is the state the hook produces - // when no UFC has been dispatched (the gateway reports false). - assertHashedTargetingKeyAndOmittedContext(piiEventDefaultConsent()); - } - - @Test - void eventConsentFalseStaysHashedEvenWhenGatewayLaterReportsTrue() throws Exception { - // Regression guard: consent is decided by the value the event carried at evaluation time, never - // re-read from the gateway at flush. An event evaluated under consent=false must stay hashed - // even if a later RC update turns the gateway's consent on before the flush drains. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(false)); - - // Flip the gateway's consent on before both aggregation and flush; the event's evaluation-time - // snapshot (false) must win at every downstream step, so neither may consult the gateway. - dispatchObserveFullEvaluationData(true); - setup.handler.drainAndAggregate(); - - final java.util.List captured = new java.util.ArrayList<>(); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.flush(); - - assertEquals(1, captured.size()); - final FlagEvaluationTestSupport.CapturedJson json = - FlagEvaluationTestSupport.readJson(captured.get(0)); - final Map ev = eventForFlag(json.parsed, "pii-flag"); - assertNotNull(ev); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - assertTrue(json.raw.contains(HASHED_JANE_DOE)); - assertFalse(json.raw.contains("jane.doe@datadoghq.com")); - } - - @Test - void eventConsentTrueStaysRawEvenWhenGatewayLaterReportsFalse() throws Exception { - // Symmetric guard: an event evaluated under consent=true must stay raw even if a later RC - // update - // turns the gateway's consent off before aggregation and flush. Together with the false-stays- - // hashed test this pins that neither aggregation nor flush ever consults the gateway. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent(true)); - - dispatchObserveFullEvaluationData(false); - setup.handler.drainAndAggregate(); - - final java.util.List captured = new java.util.ArrayList<>(); - when(mockEvp.post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false))) - .thenAnswer( - inv -> { - captured.add(inv.getArgument(1)); - return null; - }); - setup.handler.flush(); - - assertEquals(1, captured.size()); - final Map ev = - eventForFlag(FlagEvaluationTestSupport.readJson(captured.get(0)).parsed, "pii-flag"); - assertNotNull(ev); - assertEquals("jane.doe@datadoghq.com", ev.get("targeting_key")); - final Map ctx = (Map) ev.get("context"); - assertNotNull(ctx); - assertNotNull(ctx.get("evaluation")); - } - - @Test - void consentOffPreservesErrorCodeSignalAndNeverLeaksPiiInErrorMessage() throws Exception { - // Upstream contract: the hook substitutes the ErrorCode name for the raw exception message - // under consent-off (see - // FlagEvalLoggingHookTest#errorMessageReplacedByErrorCodeUnderConsentOff). - // This wire-level guard pins that a properly-formed consent-off event (a) still surfaces the - // stable ErrorCode signal for operators and (b) never lets a PII-shaped string escape onto the - // wire. Mirrors the existing PII guards on the targeting_key axis. - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add( - new FlagEvalEvent( - "err-flag", - null, - "alloc1", - "jane.doe@datadoghq.com", - "TYPE_MISMATCH", - 1000L, - false, - emptyMap())); - - final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); - - final Map ev = eventForFlag(captured.parsed, "err-flag"); - assertNotNull(ev); - final Map error = (Map) ev.get("error"); - assertNotNull(error, "error object must be present so operators keep the ErrorCode signal"); - assertEquals("TYPE_MISMATCH", error.get("message")); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); - assertFalse( - captured.raw.contains("For input string"), - "no exception-message-shaped text may reach the wire under consent-off"); - } - - @Test - void encodeFailureClearsAggregatorSoLaterFlushesRecover() throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - - // Moshi rejects non-finite JSON numbers. A NaN in the context poisons buildPayloads for this - // bucket. Before the fix, the aggregator kept the bucket and every later flush re-threw. - final Map poison = new HashMap<>(); - poison.put("bad-number", Double.NaN); - setup.handler.add(event("poison-flag", "on", "alloc1", "user-1", 1000L, true, poison)); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - verify(mockEvp, org.mockito.Mockito.never()) - .post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - - // The bucket must not survive the failed flush. A follow-up healthy event flushes cleanly. - setup.handler.add(simpleEvent("healthy-flag", "on")); - setup.handler.drainAndAggregate(); - setup.handler.flush(); - verify(mockEvp).post(eq("flagevaluation"), any(RequestBody.class), any(), any(), eq(false)); - } - - @Test - void scoConstructorCreatesUsableWriter() { - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl(new SharedCommunicationObjects(), cfg()); - writer.enqueue(simpleEvent("sco-flag", "on")); - assertNotNull(writer.pollQueuedEventForTest()); - writer.close(); - } - - @Test - void agentlessWritesFlagEvaluationsDirectlyWhenLocalProxyIsUnavailable() throws Exception { - try (JavaTestHttpServer server = - JavaTestHttpServer.httpServer( - s -> - s.handlers( - h -> - h.prefix( - DIRECT_FLAG_EVALUATION_ENDPOINT, - api -> api.getResponse().status(200).send("OK"))))) { - final Config config = cfg(); - when(config.getFeatureFlaggingConfigurationSource()) - .thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final OkHttpClient client = new OkHttpClient.Builder().build(); - try { - final IntakeApi directApi = - new IntakeApi( - HttpUrl.get(server.getAddress()).resolve("/api/v2/"), - API_KEY, - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - client, - false); - when(backendApiFactory.createDirectIntakeApi( - eq(Intake.EVENT_PLATFORM), eq(false), eq(false))) - .thenReturn(directApi); - final FeatureFlagBackendApiFactory featureFlagBackendApiFactory = - new FeatureFlagBackendApiFactory( - config, backendApiFactory, FeatureFlagEventType.FLAG_EVALUATION); - final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); - - try (FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, 1, TimeUnit.MILLISECONDS, featureFlagBackendApiFactory::create, config)) { - writer.startForTest(); - writer.enqueue(simpleEvent("direct-flag", "on")); - - poll.eventually( - () -> { - assertNotNull(server.getLastRequest()); - assertEquals(DIRECT_FLAG_EVALUATION_ENDPOINT, server.getLastRequest().getPath()); - assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); - assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); - assertTrue(server.getLastRequest().getBody().length > 0); - }); - } - } finally { - client.dispatcher().executorService().shutdownNow(); - client.connectionPool().evictAll(); - } - } - } - - @Test - void countContextTruncatedAccumulatesPerReason() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - 16, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - writer.countContextTruncated("field_count"); - writer.countContextTruncated("field_count"); - writer.countContextTruncated("field_length"); - writer.flushForTest(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 2, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, - "reason:field_count")); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_CONTEXT_TRUNCATED_METRIC, - "reason:field_length")); - } - - @Test - void hasCapacityForEnqueueReflectsQueueSaturationAndCountsPreQueueOverflow() { - final BackendApi mockEvp = mock(BackendApi.class); - final BackendApiFactory factory = mock(BackendApiFactory.class); - when(factory.createBackendApi(any(), anyBoolean())).thenReturn(mockEvp); - final int capacity = 2; - final FlagEvaluationWriterImpl writer = - new FlagEvaluationWriterImpl( - capacity, Long.MAX_VALUE, TimeUnit.NANOSECONDS, backendApiSupplier(factory), cfg()); - - assertTrue(writer.hasCapacityForEnqueue()); - - // Saturate the hand-off queue without starting the worker so no drain can free slots. - for (int i = 0; i < capacity; i++) { - writer.enqueue(simpleEvent("cap-flag-" + i, "on")); - } - assertFalse(writer.hasCapacityForEnqueue()); - - // Simulate a pre-queue overflow account by the hook and surface it on flush. - writer.countPreQueueOverflow(); - writer.flushForTest(); - - final Collection metrics = - CoreMetricCollector.getInstance().drain(); - assertEquals( - 1, - metricSum( - metrics, - FlagEvaluationWriterImpl.FLAG_EVALUATION_DROPPED_METRIC, - "reason:" + FlagEvaluationWriterImpl.DROP_REASON_QUEUE_OVERFLOW)); - - writer.close(); - } - - private void assertHashedTargetingKeyAndOmittedContext(final FlagEvalEvent piiEvent) - throws Exception { - final BackendApi mockEvp = mock(BackendApi.class); - final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(mockEvp); - setup.handler.add(piiEvent); - - final FlagEvaluationTestSupport.CapturedJson captured = flushAndCapture(setup); - - final Map ev = eventForFlag(captured.parsed, "pii-flag"); - assertNotNull(ev); - assertEquals(HASHED_JANE_DOE, ev.get("targeting_key")); - assertFalse(ev.containsKey("context")); - // The raw wire bytes must carry the hashed key and never leak the raw PII value or a per-event - // evaluation context (the batch envelope owns the top-level "context" key, so guard on the - // nested "evaluation" field instead). - assertTrue(captured.raw.contains(HASHED_JANE_DOE)); - assertFalse(captured.raw.contains("jane.doe@datadoghq.com")); - assertFalse(captured.raw.contains("\"evaluation\":")); - } - - private static FlagEvalEvent piiEvent(final boolean observeFullEvaluationData) { - return event( - "pii-flag", - "on", - "alloc1", - "jane.doe@datadoghq.com", - 1000L, - observeFullEvaluationData, - piiAttrs()); - } - - private static FlagEvalEvent piiEventDefaultConsent() { - return event("pii-flag", "on", "alloc1", "jane.doe@datadoghq.com", 1000L, piiAttrs()); - } - - private static Map piiAttrs() { - final Map attrs = new HashMap<>(); - attrs.put("region", "us-east-1"); - return attrs; - } - - private static void dispatchObserveFullEvaluationData(final boolean value) { - FeatureFlaggingGateway.dispatch( - new ServerConfiguration( - "2024-04-17T19:40:53.716Z", "SERVER", value, null, java.util.Collections.emptyMap())); - } - - private static Object lifecycleLock(final FlagEvaluationWriterImpl writer) throws Exception { - final Field field = FlagEvaluationWriterImpl.class.getDeclaredField("lifecycleLock"); - field.setAccessible(true); - return field.get(writer); - } - - private static void awaitThreadState(final Thread thread, final Thread.State state) - throws InterruptedException { - final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); - while (thread.getState() != state && System.nanoTime() < deadline) { - Thread.sleep(10); - } - assertEquals(state, thread.getState()); - } - - private static Map context() { - final Map context = new HashMap<>(); - context.put("service", "test-service"); - return context; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java index f11816a9bc0..64389bfcaaa 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/RemoteConfigServiceImplTest.java @@ -1,540 +1,120 @@ package com.datadog.featureflag; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Collections.emptyList; -import static java.util.Collections.emptyMap; -import static java.util.Collections.emptySet; -import static java.util.Collections.singleton; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import com.squareup.moshi.JsonAdapter; -import com.squareup.moshi.JsonReader; -import com.squareup.moshi.JsonWriter; -import com.squareup.moshi.Moshi; -import com.squareup.moshi.Types; -import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.remoteconfig.Capabilities; import datadog.remoteconfig.ConfigurationDeserializer; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.PollingRateHinter; import datadog.remoteconfig.Product; -import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.ufc.v1.Allocation; -import datadog.trace.api.featureflag.ufc.v1.Feature; -import datadog.trace.api.featureflag.ufc.v1.Flag; -import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; -import java.io.IOException; -import java.io.InputStream; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.time.Instant; -import java.util.Arrays; -import java.util.Date; +import java.util.ArrayList; import java.util.List; -import java.util.Map; -import okio.Buffer; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.tabletest.junit.TableTest; -@ExtendWith(MockitoExtension.class) class RemoteConfigServiceImplTest { - - @Mock private FeatureFlaggingGateway.ConfigListener listener; - @Captor private ArgumentCaptor deserializerCaptor; - - @AfterEach - void cleanup() { - FeatureFlaggingGateway.removeConfigListener(listener); - } + private static final byte[] DOCUMENT = "{\"flags\":{}}".getBytes(UTF_8); @Test - void testNewConfigReceived() throws Exception { + @SuppressWarnings("unchecked") + void subscriptionRegistersTheProductWithARawDeserializer() throws Exception { final ConfigurationPoller poller = mock(ConfigurationPoller.class); - final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); - when(sco.configurationPoller(any(Config.class))).thenReturn(poller); - FeatureFlaggingGateway.addConfigListener(listener); - final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(sco, Config.get()); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); - service.init(); + service.subscribe(content -> {}); + service.subscribe(content -> {}); verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).addListener(eq(Product.FFE_FLAGS), deserializerCaptor.capture(), eq(service)); - - final ServerConfiguration config = deserializer().deserialize(emptyConfig().getBytes(UTF_8)); - service.accept("test", config, mock(PollingRateHinter.class)); - - verify(listener).accept(any(ServerConfiguration.class)); - - service.close(); - - verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - verify(poller).removeListeners(Product.FFE_FLAGS); - } - - @Test - void skipsMalformedFlagAllocationsAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("malformed-allocations.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("malformed-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void parsesSplitSerialId() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("340132")); - - assertNotNull(config); - assertEquals(Integer.valueOf(340132), serialIdOf(config)); - } - - @Test - void parsesSplitSerialIdZero() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("0")); - - assertNotNull(config); - assertEquals(Integer.valueOf(0), serialIdOf(config)); - } - - @Test - void parsesAbsentSplitSerialIdAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId(null)); - - assertNotNull(config); - assertNull(serialIdOf(config)); - } - - @Test - void parsesNullSplitSerialIdAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId("null")); - - assertNotNull(config); - assertNull(serialIdOf(config)); - } - - @Test - void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { - final ServerConfiguration config = deserialize(configWithSiblingSerialIds("true")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("malformed-flag")); - assertEquals("invalid_flag", config.invalidFlags.get("malformed-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - /** - * Records how leniently the per-flag value reader coerces a serial id, so a future change to the - * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no - * validation of its own; what matters is that a bad one never rejects the sibling flag. - */ - @ParameterizedTest - @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) - void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) - throws Exception { - final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); - - assertNotNull(config); - assertEquals(Integer.valueOf(expected), serialIdOf(config)); - } - - @Test - void parsesSplitFeatures() throws Exception { - final ServerConfiguration config = - deserialize( - configWithFeatures( - "[{\"key\": \"holdout.key\", \"value\": \"q4-global\", \"destinations\": [\"HOOK\"]}," - + " {\"key\": \"holdout.weight\", \"value\": 0.5," - + " \"destinations\": [\"EXPOSURE\", \"EVALUATION\"]}," - + " {\"key\": \"holdout.should_include_in_holdout_analysis\", \"value\": true," - + " \"destinations\": [\"HOOK\", \"SOME_FUTURE_DESTINATION\"]}]")); - - final List features = featuresOf(config); - assertEquals(3, features.size()); - assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); - assertFeature(features.get(1), "holdout.weight", 0.5, "EXPOSURE", "EVALUATION"); - assertFeature( - features.get(2), - "holdout.should_include_in_holdout_analysis", - true, - "HOOK", - "SOME_FUTURE_DESTINATION"); - assertEquals(Integer.valueOf(7), serialIdOf(config)); + final ArgumentCaptor> deserializer = + ArgumentCaptor.forClass(ConfigurationDeserializer.class); + verify(poller).addListener(eq(Product.FFE_FLAGS), deserializer.capture(), eq(service)); + verify(poller).start(); + assertArrayEquals(DOCUMENT, deserializer.getValue().deserialize(DOCUMENT)); } @Test - void parsesAbsentSplitFeaturesAsNull() throws Exception { - final ServerConfiguration config = deserialize(configWithFeatures(null)); - - assertNull(featuresOf(config)); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } + void forwardsDocumentsAndRemovalsToAllSubscribers() { + final RemoteConfigServiceImpl service = + new RemoteConfigServiceImpl(mock(ConfigurationPoller.class)); + final List first = new ArrayList<>(); + final List second = new ArrayList<>(); + service.subscribe(first::add); + service.subscribe(second::add); - @Test - void dropsMalformedSplitFeaturesAndKeepsTheFlag() throws Exception { - final String hook = ", \"destinations\": [\"HOOK\"]"; - final ServerConfiguration config = - deserialize( - configWithFeatures( - "[{\"key\": \"object-value\", \"value\": {\"nested\": true}" - + hook - + "}," - + " {\"key\": \"array-value\", \"value\": [1]" - + hook - + "}," - + " {\"key\": \"null-value\", \"value\": null" - + hook - + "}," - + " {\"key\": \"\", \"value\": \"empty key\"" - + hook - + "}," - + " {\"key\": 3, \"value\": \"numeric key\"" - + hook - + "}," - + " {\"value\": \"missing key\"" - + hook - + "}," - + " {\"key\": \"missing value\"" - + hook - + "}," - + " {\"key\": \"no-destinations\", \"value\": \"dropped\"}," - + " {\"key\": \"empty-destinations\", \"value\": \"dropped\", \"destinations\": []}," - + " {\"key\": \"destinations-not-a-list\", \"value\": \"dropped\"," - + " \"destinations\": \"HOOK\"}," - + " {\"key\": \"only-invalid-destinations\", \"value\": \"dropped\"," - + " \"destinations\": [1, \"\", null, {\"name\": \"HOOK\"}]}," - + " \"not-an-object\"," - + " {\"key\": \"holdout.key\", \"value\": \"q4-global\"," - + " \"destinations\": [1, \"HOOK\", \"\"], \"destination\": \"HOOK\"}]")); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); + service.accept("key", null, mock(PollingRateHinter.class)); - assertTrue(config.flags.containsKey("valid-flag")); - final List features = featuresOf(config); - assertEquals(1, features.size()); - assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); + assertEquals(2, first.size()); + assertArrayEquals(DOCUMENT, first.get(0)); + assertNull(first.get(1)); + assertEquals(2, second.size()); } @Test - void ignoresSplitFeaturesThatAreNotAList() throws Exception { - final ServerConfiguration config = - deserialize(configWithFeatures("{\"holdout.key\": \"q4-global\"}")); + void replaysTheCurrentDocumentToLateSubscribers() { + final RemoteConfigServiceImpl service = + new RemoteConfigServiceImpl(mock(ConfigurationPoller.class)); + service.subscribe(content -> {}); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); - assertTrue(config.flags.containsKey("valid-flag")); - assertNull(featuresOf(config)); - assertEquals(Integer.valueOf(7), serialIdOf(config)); - } - - private static void assertFeature( - final Feature feature, final String key, final Object value, final String... destinations) { - assertEquals(key, feature.key); - assertEquals(value, feature.value); - assertEquals(Arrays.asList(destinations), feature.destinations); - } + final List late = new ArrayList<>(); + service.subscribe(late::add); - private static List featuresOf(final ServerConfiguration config) { - return config.flags.get("valid-flag").allocations.get(0).splits.get(0).features; - } - - /** Inserts the raw features JSON; null omits the key. */ - private static String configWithFeatures(final String featuresJson) throws IOException { - final String json = resource("split-features.json"); - return featuresJson == null - ? json.replace("\"features\": \"${features}\",", "") - : json.replace("\"${features}\"", featuresJson); - } - - private static Integer serialIdOf(final ServerConfiguration config) { - return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; - } - - private static String configWithSerialId(final String serialIdJson) throws IOException { - return withSerialId(resource("serial-id.json"), serialIdJson); - } - - /** A malformed serial id must bind to its own flag and leave the sibling flag intact. */ - private static String configWithSiblingSerialIds(final String malformedSerialIdJson) - throws IOException { - return withSerialId(resource("sibling-serial-ids.json"), malformedSerialIdJson); - } - - /** Inserts the raw wire value so the parser sees its original JSON type; null omits the key. */ - private static String withSerialId(final String json, final String serialIdJson) { - return serialIdJson == null - ? json.replace("\"serialId\": \"${serialId}\",", "") - : json.replace("\"${serialId}\"", serialIdJson); + assertEquals(1, late.size()); + assertArrayEquals(DOCUMENT, late.get(0)); } @Test - void ignoresUnknownTopLevelFields() throws Exception { - final ServerConfiguration config = deserialize(resource("unknown-top-level-fields.json")); - - assertNotNull(config); - assertEquals("2024-04-17T19:40:53.716Z", config.createdAt); - assertEquals("SERVER", config.format); - assertNotNull(config.environment); - assertEquals("Test", config.environment.name); - assertTrue(config.flags.isEmpty()); - } - - @Test - void parsesAllocationWindowDatesAsDateFieldsWithInstantAccessors() throws Exception { - final ServerConfiguration config = deserialize(resource("allocation-window-dates.json")); - - final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); - assertEquals(Date.class, Allocation.class.getField("startAt").getType()); - assertEquals(Date.class, Allocation.class.getField("endAt").getType()); - assertEquals(Instant.parse("2023-01-01T00:00:00.123Z"), allocation.startAt.toInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00.987Z"), allocation.endAt.toInstant()); - assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAtInstant()); - assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAtInstant()); - } - - @Test - void rejectsTrailingJson() { - assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); - } - - @Test - void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("unknown-operator.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("operator-grease-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void flagMapAdapterFactoryOnlyCreatesFlagMapAdapterForFlagMapType() { - final Moshi moshi = moshi(); - final Type flagsType = Types.newParameterizedType(Map.class, String.class, Flag.class); - - final JsonAdapter adapter = - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(flagsType, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.FlagMapAdapter); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create(String.class, emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.FlagMapAdapter.FACTORY.create( - flagsType, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void lenientBooleanAdapterFactoryOnlyCreatesAdapterForUnannotatedBoxedBoolean() { - final Moshi moshi = moshi(); - - final JsonAdapter adapter = - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - Boolean.class, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.LenientBooleanAdapter); - // Primitive boolean keeps Moshi's strict adapter so mandatory fields still reject bad values. - assertNull( - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - boolean.class, emptySet(), moshi)); - // A qualified Boolean belongs to whichever adapter declared the qualifier, not to this one. - assertNull( - UniversalFlagConfigParser.LenientBooleanAdapter.FACTORY.create( - Boolean.class, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void lenientBooleanAdapterIsReadOnly() { - final UniversalFlagConfigParser.LenientBooleanAdapter adapter = - new UniversalFlagConfigParser.LenientBooleanAdapter(); - - assertThrows( - UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), true)); - } - - @Test - void featureListAdapterFactoryOnlyCreatesAdapterForUnannotatedFeatureList() { - final Moshi moshi = moshi(); - final Type featuresType = Types.newParameterizedType(List.class, Feature.class); - - final JsonAdapter adapter = - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - featuresType, emptySet(), moshi); - - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.FeatureListAdapter); - assertNull( - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - Types.newParameterizedType(List.class, String.class), emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.FeatureListAdapter.FACTORY.create( - featuresType, singleton(mock(Annotation.class)), moshi)); - } - - @Test - void featureListAdapterIsReadOnly() { - final UniversalFlagConfigParser.FeatureListAdapter adapter = - new UniversalFlagConfigParser.FeatureListAdapter(); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyList())); - } - - @Test - void allowsNullFlagMap() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flags.json")); - - assertNotNull(config); - assertNull(config.flags); - } - - @Test - void skipsNullFlagAndKeepsValidFlag() throws Exception { - final ServerConfiguration config = deserialize(resource("null-flag.json")); - - assertNotNull(config); - assertFalse(config.flags.containsKey("null-flag")); - assertTrue(config.flags.containsKey("valid-flag")); - assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); - } - - @Test - void flagMapAdapterIsReadOnly() { - final UniversalFlagConfigParser.FlagMapAdapter adapter = - new UniversalFlagConfigParser.FlagMapAdapter(moshi().adapter(Flag.class)); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), emptyMap())); - } - - @Test - void allocationAdapterFactoryOnlyCreatesAllocationAdapterForAllocationType() { - final Moshi moshi = moshi(); + void startRegistersTheProductOnceBeforeAnySubscription() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); - final JsonAdapter adapter = - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - Allocation.class, emptySet(), moshi); + service.start(); + service.start(); + service.subscribe(content -> {}); - assertNotNull(adapter); - assertTrue(adapter instanceof UniversalFlagConfigParser.AllocationAdapter); - assertNull( - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - String.class, emptySet(), moshi)); - assertNull( - UniversalFlagConfigParser.AllocationAdapter.FACTORY.create( - Allocation.class, singleton(mock(Annotation.class)), moshi)); + verify(poller, times(1)).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller, times(1)).start(); } @Test - void allocationAdapterHandlesNullAndIsReadOnly() throws Exception { - final UniversalFlagConfigParser.AllocationAdapter adapter = - new UniversalFlagConfigParser.AllocationAdapter( - moshi().adapter(UniversalFlagConfigParser.AllocationJson.class)); - - assertNull(adapter.fromJson("null")); - assertThrows( - UnsupportedOperationException.class, () -> adapter.toJson(mock(JsonWriter.class), null)); - } + void unsubscribedSubscribersStopReceivingDocuments() throws Exception { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); + final List received = new ArrayList<>(); + final AutoCloseable subscription = service.subscribe(received::add); - @TableTest({ - "scenario | value | expectedInstant ", - "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", - "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", - "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", - "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", - "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", - "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", - "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", - "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", - "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", - "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", - "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", - "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", - "date only | '2023-01-01' | ", - "invalid | 'invalid-date' | ", - "empty string | '' | ", - "not a date | 'not-a-date' | ", - "slash date | '2023/01/01T00:00:00Z' | ", - "null | | " - }) - void testInstantParsing(final String value, final String expectedInstant) throws Exception { - final JsonReader reader = mock(JsonReader.class); - if (value == null) { - when(reader.peek()).thenReturn(JsonReader.Token.NULL); - when(reader.nextNull()).thenReturn(null); - } else { - when(reader.peek()).thenReturn(JsonReader.Token.STRING); - when(reader.nextString()).thenReturn(value); - } - final UniversalFlagConfigParser.InstantAdapter adapter = - new UniversalFlagConfigParser.InstantAdapter(); + subscription.close(); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); - final Instant parsed = adapter.fromJson(reader); - if (value == null) { - verify(reader).nextNull(); - } - if (expectedInstant == null) { - assertNull(parsed); - } else { - assertNotNull(parsed); - assertEquals(expectedInstant, parsed.toString()); - } + assertEquals(0, received.size()); + verify(poller, never()).removeListeners(any()); } @Test - void testParsingOnlyAdapter() { - final UniversalFlagConfigParser.InstantAdapter adapter = - new UniversalFlagConfigParser.InstantAdapter(); - - assertThrows( - UnsupportedOperationException.class, - () -> adapter.toJson(mock(JsonWriter.class), Instant.EPOCH)); - } - - @SuppressWarnings("unchecked") - private ConfigurationDeserializer deserializer() { - return deserializerCaptor.getValue(); - } - - private static ServerConfiguration deserialize(final String json) throws Exception { - return UniversalFlagConfigParser.INSTANCE.deserialize(json.getBytes(UTF_8)); - } - - private static Moshi moshi() { - return new Moshi.Builder() - .add(Instant.class, new UniversalFlagConfigParser.InstantAdapter()) - .add(UniversalFlagConfigParser.AllocationAdapter.FACTORY) - .build(); - } + void closeUnregistersTheProductAndDropsSubscribers() { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + final RemoteConfigServiceImpl service = new RemoteConfigServiceImpl(poller); + final List received = new ArrayList<>(); + service.subscribe(received::add); - private static String emptyConfig() throws IOException { - return resource("empty-config.json"); - } + service.close(); + service.close(); + service.accept("key", DOCUMENT, mock(PollingRateHinter.class)); - private static String resource(final String name) throws IOException { - try (final InputStream stream = - RemoteConfigServiceImplTest.class.getResourceAsStream("/remote-config/" + name)) { - assertNotNull(stream, "Missing remote-config fixture: " + name); - return new Buffer().readFrom(stream).readUtf8(); - } + verify(poller, times(1)) + .removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller, times(1)).removeListeners(Product.FFE_FLAGS); + verify(poller, never()).stop(); + assertEquals(0, received.size()); } } 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 index 662fe928658..4f76e732432 100644 --- 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 @@ -11,15 +11,14 @@ 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). + * Write-tier suite: the agent-side {@link SpanEnrichmentWriter} translates the evaluations recorded + * by the SDK into per-local-root accumulator state (resolving the active root through an injectable + * resolver so no static tracer is needed). */ class SpanEnrichmentWriterTest { @@ -37,7 +36,7 @@ private static SpanEnrichmentWriter writerFor(final AgentSpan root) { void serialIdWithDoLogAndTargetingKeyRecordsSerialAndSubject() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(42, true, "user-1")); + writer.serialId(42, true, "user-1"); final SpanEnrichmentAccumulator state = writer.states().peek(root); assertNotNull(state); @@ -49,7 +48,7 @@ void serialIdWithDoLogAndTargetingKeyRecordsSerialAndSubject() { void serialIdWithoutDoLogRecordsNoSubject() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(7, false, "user-1")); + writer.serialId(7, false, "user-1"); final SpanEnrichmentAccumulator state = writer.states().peek(root); assertTrue(state.serialIdsView().contains(7)); @@ -60,7 +59,7 @@ void serialIdWithoutDoLogRecordsNoSubject() { void serialIdWithNullTargetingKeyRecordsNoSubject() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.serialId(9, true, null)); + writer.serialId(9, true, null); final SpanEnrichmentAccumulator state = writer.states().peek(root); assertTrue(state.serialIdsView().contains(9)); @@ -71,7 +70,7 @@ void serialIdWithNullTargetingKeyRecordsNoSubject() { void runtimeDefaultRecordsDefault() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.runtimeDefault("flag", Collections.singletonMap("k", "v"))); + writer.runtimeDefault("flag", Collections.singletonMap("k", "v")); final SpanEnrichmentAccumulator state = writer.states().peek(root); assertNotNull(state); @@ -81,23 +80,15 @@ void runtimeDefaultRecordsDefault() { @Test void noActiveSpanAccumulatesNothing() { final SpanEnrichmentWriter writer = new SpanEnrichmentWriter(() -> null); - writer.accept(SpanEnrichmentEvent.serialId(1, true, "user-1")); + writer.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.serialId(5, false, null); writer.interceptor().onTraceComplete(Collections.singletonList(root)); verify(root).setTag(SpanEnrichmentAccumulator.TAG_FLAGS_ENC, "BQ=="); // {5} -> 0x05 @@ -109,7 +100,7 @@ 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)); + writer.serialId(5, false, null); assertTrue( writer.states().isEmpty(), "no accumulation when the interceptor cannot be registered"); } @@ -151,12 +142,11 @@ void resolveLocalRootLogic() { } @Test - void initSubscribesAndCloseClearsState() { + void closeClearsState() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.init(); try { - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + writer.serialId(5, false, null); assertNotNull(writer.states().peek(root)); } finally { writer.close(); @@ -168,21 +158,21 @@ void initSubscribesAndCloseClearsState() { void runtimeDefaultWithNullFlagKeyIsIgnored() { final AgentSpan root = rootSpan(); final SpanEnrichmentWriter writer = writerFor(root); - writer.accept(SpanEnrichmentEvent.runtimeDefault(null, "v")); + writer.runtimeDefault(null, "v"); // No serial id and null flag key → nothing recorded (getOrCreate ran, but no data added). final SpanEnrichmentAccumulator state = writer.states().peek(root); assertTrue(state == null || !state.hasData()); } @Test - void acceptSwallowsResolverErrors() { + void recordingSwallowsResolverErrors() { final SpanEnrichmentWriter writer = new SpanEnrichmentWriter( () -> { throw new RuntimeException("resolver boom"); }); // Must not propagate — enrichment can never break flag evaluation. - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + writer.serialId(5, false, null); assertTrue(writer.states().isEmpty()); } @@ -196,7 +186,7 @@ void registrarErrorIsSwallowedAndNotLatched() { throw new RuntimeException("register boom"); }); // Registration throws → swallowed, not latched, and nothing accumulates (never flushable). - writer.accept(SpanEnrichmentEvent.serialId(5, false, null)); + writer.serialId(5, false, null); assertTrue(writer.states().isEmpty()); } @@ -204,8 +194,8 @@ void registrarErrorIsSwallowedAndNotLatched() { 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)); + writer.serialId(100, false, null); + writer.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 67fbb3ad7a0..a4e2b8ab815 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,6 +61,7 @@ rootProject.name = "dd-trace-java" // external apis include( + ":dd-openfeature", ":dd-trace-api", ":dd-trace-ot", ":dd-trace-ot:correlation-id-injection", @@ -163,7 +164,6 @@ include(":dd-java-agent:agent-aiguard") // Feature Flagging 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" @@ -344,6 +344,7 @@ include( ":dd-java-agent:instrumentation:cxf-2.1", ":dd-java-agent:instrumentation:datadog:asm:iast-instrumenter", ":dd-java-agent:instrumentation:datadog:dynamic-instrumentation:span-origin", + ":dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector", ":dd-java-agent:instrumentation:datadog:profiling:enable-wallclock-profiling", ":dd-java-agent:instrumentation:datadog:profiling:exception-profiling", ":dd-java-agent:instrumentation:datadog:tracing:trace-annotation", From eb82d39d7aa6246d26a887ebcae7ba9146f4462c Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 1 Oct 2026 16:58:46 +0200 Subject: [PATCH 02/50] feat(ffe): Add new instrumentation config --- metadata/supported-configurations.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 84ee1174006..cafadc1d6b8 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -8660,6 +8660,14 @@ "aliases": ["DD_TRACE_INTEGRATION_OPENAI_JAVA_ENABLED", "DD_INTEGRATION_OPENAI_JAVA_ENABLED"] } ], + "DD_TRACE_OPENFEATURE_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "true", + "aliases": ["DD_TRACE_INTEGRATION_OPENFEATURE_ENABLED", "DD_INTEGRATION_OPENFEATURE_ENABLED"] + } + ], "DD_TRACE_OPENSEARCH_ANALYTICS_ENABLED": [ { "version": "A", From 90c1f314bf52fa3fccb124a47899eece705539f1 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 1 Oct 2026 17:02:53 +0200 Subject: [PATCH 03/50] feat(ffe): Allow vm props and env vars access --- .../com/datadog/openfeature/internal/config/Settings.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index 1c93647f1f5..d79d3e69e91 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -1,6 +1,7 @@ package com.datadog.openfeature.internal.config; import com.datadog.openfeature.internal.connector.Connector; +import de.thetaphi.forbiddenapis.SuppressForbidden; import java.util.Locale; import java.util.function.UnaryOperator; import javax.annotation.Nullable; @@ -58,6 +59,9 @@ public final class Settings { * @param connector the connector to look settings up from first. * @return the settings. */ + // Allowing VM properties and environment variable access + // as the SDK does not ship the agent configuration helpers + @SuppressForbidden public static Settings load(final Connector connector) { return new Settings(connector, System::getProperty, System::getenv); } From ff86e4fa2904d9a4430840eeaaa56da2dcfcef2e Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 1 Oct 2026 17:04:00 +0200 Subject: [PATCH 04/50] feat(ffe): Suppress static sync warning --- .../java/com/datadog/featureflag/FeatureFlaggingSystem.java | 4 ++++ 1 file changed, 4 insertions(+) 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 30366542376..c723be3e2f9 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 @@ -19,6 +19,10 @@ public class FeatureFlaggingSystem { private FeatureFlaggingSystem() {} + @SuppressFBWarnings( + value = "USO_UNSAFE_STATIC_METHOD_SYNCHRONIZATION", + justification = + "Agent-internal class; Class object does not escape to app code and lock only guards the subsystem lifecycle.") public static synchronized void start(final SharedCommunicationObjects sco) { if (BACKEND != null) { LOGGER.debug("Feature Flagging system already started"); From f9a0f802cd689a032eb1bd075793487154122481 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 1 Oct 2026 17:05:00 +0200 Subject: [PATCH 05/50] fix(ffe): Fix dd-openfeature system test versions --- .github/workflows/run-system-tests.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-system-tests.yaml b/.github/workflows/run-system-tests.yaml index 4b0ad663659..7b983f14e5e 100644 --- a/.github/workflows/run-system-tests.yaml +++ b/.github/workflows/run-system-tests.yaml @@ -49,8 +49,11 @@ jobs: JAVA_11_HOME=$JAVA_HOME_11_X64 \ JAVA_17_HOME=$JAVA_HOME_17_X64 \ JAVA_21_HOME=$JAVA_HOME_21_X64 \ - ./gradlew clean :dd-java-agent:shadowJar \ + ./gradlew clean :dd-java-agent:shadowJar :dd-openfeature:jar \ --build-cache --parallel --stacktrace --no-daemon --max-workers=4 + # System tests install a dd-openfeature jar found next to the agent instead of the published one, + # as the agent supports only the dd-openfeature version built along with it. + cp workspace/dd-openfeature/build/libs/dd-openfeature-*.jar workspace/dd-java-agent/build/libs/ - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a From 4704d9a4edf9ea83e07328fceab3fabf152ea0b6 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 1 Oct 2026 17:05:31 +0200 Subject: [PATCH 06/50] fix(ffe): Fix reader not closed --- .../openfeature/internal/config/SdkVersion.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java index c100d98e400..b4ea3613ea9 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/SdkVersion.java @@ -14,12 +14,13 @@ public final class SdkVersion { private SdkVersion() {} private static String readVersion() { - try (InputStream stream = SdkVersion.class.getResourceAsStream("/dd-openfeature.version")) { - if (stream == null) { - return "unknown"; - } - final String line = - new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8)).readLine(); + final InputStream stream = SdkVersion.class.getResourceAsStream("/dd-openfeature.version"); + if (stream == null) { + return "unknown"; + } + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + final String line = reader.readLine(); // Strip the git hash suffix. final int hash = line == null ? -1 : line.indexOf('~'); return line == null ? "unknown" : hash < 0 ? line.trim() : line.substring(0, hash); From b9604aa127ad7ad75467dfc5bd0d27779578ac79 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 09:20:32 +0200 Subject: [PATCH 07/50] fix(ffe): Fix code test coverage --- .../featureflag/FeatureFlagsBackendTest.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java index a1aacefc85d..c7a101ef2ff 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java @@ -5,8 +5,10 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -20,6 +22,7 @@ import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import java.util.Properties; import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; class FeatureFlagsBackendTest { private final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); @@ -60,6 +63,49 @@ void resolvesSettingsWithAgentDefaultsFromTheAgentConfiguration() { assertEquals("agentless", this.backend.setting("feature.flags.configuration.source")); } + @TableTest({ + "Scenario | Source | RC enabled | Registers", + "remote config source | remote_config | true | true ", + "remote config disabled | remote_config | false | false ", + "agentless source | agentless | true | false " + }) + void startRegistersRemoteConfigOnlyWhenItIsTheSource( + final String source, final boolean rcEnabled, final boolean registers) { + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + when(this.sco.configurationPoller(this.config)).thenReturn(poller); + when(this.config.getFeatureFlaggingConfigurationSource()).thenReturn(source); + when(this.config.isRemoteConfigEnabled()).thenReturn(rcEnabled); + + this.backend.start(); + + verify(poller, times(registers ? 1 : 0)).addCapabilities(anyLong()); + } + + @TableTest({ + "Scenario | Source | API key", + "agentless with API key | agentless | key ", + "agentless without API key | agentless | ", + "remote config | remote_config | key " + }) + void createsTheBackendFromTheAgentConfiguration(final String source, final String apiKey) { + when(this.config.getFeatureFlaggingConfigurationSource()).thenReturn(source); + when(this.config.getApiKey()).thenReturn(apiKey); + + final FeatureFlagsBackend created = new FeatureFlagsBackend(this.sco, this.config); + + assertEquals(apiKey, created.setting("api-key")); + } + + @Test + void closeWithoutRemoteConfigClearsSpanEnrichment() { + this.backend.enrichSerialId(1, false, null); + + this.backend.close(); + + verify(this.sco, never()).configurationPoller(any()); + assertTrue(this.spanEnrichmentWriter.states().isEmpty()); + } + @Test void sharesOneRemoteConfigServiceAcrossSubscriptions() { final ConfigurationPoller poller = mock(ConfigurationPoller.class); From 8f6d55b70d389783fea127838ffb3919ccb99708 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 09:36:53 +0200 Subject: [PATCH 08/50] feat(ffe): Refine instrumentation --- .../dd-openfeature-connector/build.gradle | 12 ++++++------ .../openfeature/DDOpenFeatureInstrumentation.java | 4 +++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle index 4ed6ed0983d..5a8f8b3fd41 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle @@ -3,6 +3,12 @@ plugins { id 'idea' } +idea { + module { + jdkName = '11' + } +} + // TODO Add a muzzle pass directive on com.datadoghq:dd-openfeature once the SDK is published. tracerJava { @@ -30,9 +36,3 @@ tasks.named("compileMain_java11Java", JavaCompile) { tasks.named("compileTestJava", JavaCompile) { configureCompiler(it, JavaVersion.VERSION_11) } - -idea { - module { - jdkName = '11' - } -} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java index 6722e067322..dc909890988 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java @@ -1,5 +1,6 @@ package datadog.trace.instrumentation.openfeature; +import static datadog.trace.agent.tooling.InstrumenterModule.TargetSystem.FEATURE_FLAGS; import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; import static net.bytebuddy.matcher.ElementMatchers.isMethod; import static net.bytebuddy.matcher.ElementMatchers.isStatic; @@ -14,6 +15,7 @@ * Connects the {@code dd-openfeature} SDK to the agent: Remote Configuration, the event platform * proxy, health metrics and span enrichment. */ +@SuppressWarnings("unused") @AutoService(InstrumenterModule.class) public class DDOpenFeatureInstrumentation extends InstrumenterModule implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { @@ -24,7 +26,7 @@ public DDOpenFeatureInstrumentation() { @Override public boolean isApplicable(final Set enabledSystems) { - return enabledSystems.contains(TargetSystem.FEATURE_FLAGS); + return enabledSystems.contains(FEATURE_FLAGS); } @Override From 4e6c0de2d9c3a42c8e287621a5dca354d5326600 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 09:41:58 +0200 Subject: [PATCH 09/50] feat(ffe): Refine gradle configuration --- .../feature-flagging/feature-flagging-agent/build.gradle.kts | 2 -- 1 file changed, 2 deletions(-) diff --git a/products/feature-flagging/feature-flagging-agent/build.gradle.kts b/products/feature-flagging/feature-flagging-agent/build.gradle.kts index 7ba371d7dcc..b15250c5c0b 100644 --- a/products/feature-flagging/feature-flagging-agent/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-agent/build.gradle.kts @@ -1,7 +1,5 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.DependencyFilter import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar -import org.gradle.api.Action -import org.gradle.kotlin.dsl.project plugins { `java-library` From e5403ccd165559a736c744dcd6b7521b553519f7 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 09:59:44 +0200 Subject: [PATCH 10/50] feat(ffe): Clean up config --- .../dd-openfeature-connector/build.gradle | 1 + .../ConfigurationResolutionParityTest.java | 89 +++++++++++++++++++ .../openfeature/internal/config/Settings.java | 9 +- .../internal/config/SettingsTest.java | 2 +- .../config/FeatureFlaggingConfig.java | 27 ++---- 5 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle index 5a8f8b3fd41..9f1de1eb44a 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle @@ -26,6 +26,7 @@ dependencies { testImplementation project(':dd-openfeature') testImplementation project(':products:feature-flagging:feature-flagging-bootstrap') + testImplementation project(':products:feature-flagging:feature-flagging-config') testImplementation libs.bundles.mockito } diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java new file mode 100644 index 00000000000..b5091e326fd --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java @@ -0,0 +1,89 @@ +package datadog.trace.instrumentation.openfeature; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.internal.config.Settings; +import com.datadog.openfeature.internal.connector.Connector; +import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import org.tabletest.junit.TableTest; + +/** + * Guards against drift between the agent ({@link FeatureFlaggingConfig}) and the SDK ({@link + * Settings}) resolution of the Feature Flags product switch and configuration source. Both sides + * read the same raw settings, keyed by the agent setting names. + */ +class ConfigurationResolutionParityTest { + @TableTest({ + "Scenario | Enabled | Source | Legacy | Expected ", + "defaults | | | | agentless ", + "enabled | true | | | agentless ", + "enabled as number | 1 | | | agentless ", + "disabled | false | | | ", + "disabled as number | 0 | | | ", + "disabled wins over source | false | remote_config | | ", + "remote config source | | remote_config | | remote_config", + "agentless source | | agentless | | agentless ", + "source is normalized | | ' Remote_Config' | | remote_config", + "unsupported source | | offline | | ", + "legacy enabled | | | true | remote_config", + "legacy disabled | | | false | ", + "source wins over legacy | | agentless | true | agentless ", + "source wins over legacy disabled | | remote_config | false | remote_config", + "enabled with legacy disabled | true | | false | ", + "uppercase boolean | TRUE | | TRUE | remote_config", + "invalid boolean reads as false | maybe | | | ", + "invalid legacy reads as false | | | maybe | " + }) + void agentAndSdkResolveTheSameConfiguration( + final String enabled, final String source, final String legacy, final String expected) { + final Map settings = new HashMap<>(); + putIfNotNull(settings, FEATURE_FLAGS_ENABLED, enabled); + putIfNotNull(settings, FEATURE_FLAGS_CONFIGURATION_SOURCE, source); + putIfNotNull(settings, EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, legacy); + + assertEquals(expected, agentSource(settings), "agent resolution"); + assertEquals(expected, sdkSource(settings), "SDK resolution"); + } + + /** Resolves the source like the agent: {@code InstrumenterConfig} and {@code Config}. */ + private static String agentSource(final Map settings) { + final Properties properties = new Properties(); + properties.putAll(settings); + final ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(properties); + final FeatureFlaggingConfig.Resolution resolution = + resolveConfiguration( + configProvider.getBoolean(FEATURE_FLAGS_ENABLED), + configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE), + configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED)); + return resolution.isEnabled() ? resolution.getSource() : null; + } + + /** + * Resolves the source like the SDK. Missing settings are reported as blank, so the SDK does not + * fall back to the system properties and environment of the test JVM. + */ + private static String sdkSource(final Map settings) { + final Connector connector = mock(Connector.class); + when(connector.setting(anyString())) + .thenAnswer(invocation -> settings.getOrDefault(invocation.getArgument(0), "")); + return Settings.load(connector).configurationSource(); + } + + private static void putIfNotNull( + final Map map, final String key, final String value) { + if (value != null) { + map.put(key, value); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index d79d3e69e91..a175b5d43bf 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -204,15 +204,14 @@ private Boolean getBoolean(final String key) { if (value == null) { return null; } - // Same parsing as the agent: invalid values are ignored. + // Same parsing as the agent: invalid values are read as false. if ("true".equals(value) || "1".equals(value)) { return Boolean.TRUE; } - if ("false".equals(value) || "0".equals(value)) { - return Boolean.FALSE; + if (!"false".equals(value) && !"0".equals(value)) { + LOGGER.warn("Invalid boolean value for {}: {}", key, value); } - LOGGER.warn("Invalid boolean value for {}: {}", key, value); - return null; + return Boolean.FALSE; } private int getPositiveInt(final String key, final int defaultValue) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java index 934f8254bd8..c2774b4c9a9 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -56,7 +56,7 @@ void mapsKeysToEnvironmentVariables(final String key, final String variable) { "legacy enabled selects rc | | | 'true' | 'remote_config'", "legacy disabled disables | | | 'false' | ", "explicit source wins over legacy | | 'agentless' | 'false' | 'agentless' ", - "invalid enabled is ignored | 'maybe' | | | 'agentless' ", + "invalid enabled reads as false | 'maybe' | | | ", "numeric disabled | '0' | | | " }) void resolvesConfigurationSource( 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 index 1edfd5bcb26..f9ad17454f9 100644 --- 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 @@ -1,5 +1,10 @@ package datadog.trace.api.featureflag.config; +/** + * The Feature Flags settings the agent needs: the product switch and the configuration source. The + * {@code dd-openfeature} SDK resolves them with the same semantics, see {@code + * ConfigurationResolutionParityTest} in the {@code dd-openfeature-connector} instrumentation. + */ public class FeatureFlaggingConfig { public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; @@ -15,30 +20,8 @@ public class FeatureFlaggingConfig { public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; - /** - * Opt-in gate for APM span enrichment with feature-flag evaluation metadata. DISTINCT from {@link - * #FEATURE_FLAGS_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"; - - /** - * Killswitch for the EVP {@code flagevaluation} emission path. Default: enabled. Disabling it - * turns off EVP flag-evaluation counts while leaving the OTel {@code feature_flag.evaluations} - * metric path untouched. Maps to {@code DD_FLAGGING_EVALUATION_COUNTS_ENABLED}. - */ - public static final String FLAGGING_EVALUATION_COUNTS_ENABLED = - "flagging.evaluation.counts.enabled"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL = - "feature.flags.configuration.source.agentless.base.url"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS = - "feature.flags.configuration.source.agentless.poll.interval.seconds"; - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = - "feature.flags.configuration.source.agentless.request.timeout.seconds"; public static Resolution resolveConfiguration( final Boolean providerEnabled, From c86267f61c1ec00c7f1e5cfe1a762508771383e8 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 10:25:18 +0200 Subject: [PATCH 11/50] feat(ffe): Add support for older OTel APIs --- .../datadog/openfeature/FlagEvalMetrics.java | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java index 58b092e87f5..a108e0fd582 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalMetrics.java @@ -35,6 +35,7 @@ class FlagEvalMetrics implements Closeable { private volatile OpenTelemetry telemetry; private volatile boolean closed; private volatile Boolean legacyAgentBridge; + private volatile boolean getOrNoopUnavailable; private final boolean suppliedCounter; FlagEvalMetrics() { @@ -46,18 +47,13 @@ private LongCounter resolveCounter() { return counter; } try { - OpenTelemetry current = GlobalOpenTelemetry.getOrNoop(); - if (current == OpenTelemetry.noop() && hasLegacyAgentBridge()) { - // Older OTel agents bridge get(), but predate getOrNoop(). Only use get() when the - // agent has injected its API bridge. Mere agent-JAR presence is not sufficient. - current = GlobalOpenTelemetry.get(); - } + final OpenTelemetry current = currentOpenTelemetry(); if (current == telemetry) { return counter; } return initializeCounter(current); } catch (LinkageError e) { - log.debug("OpenTelemetry API 1.57 or newer is required for evaluation metrics", e); + log.debug("OpenTelemetry API is not available for evaluation metrics", e); shutdown(); } catch (Exception e) { log.error("Failed to initialize flag evaluation metrics", e); @@ -65,6 +61,24 @@ private LongCounter resolveCounter() { return null; } + private OpenTelemetry currentOpenTelemetry() { + if (!getOrNoopUnavailable) { + try { + OpenTelemetry current = GlobalOpenTelemetry.getOrNoop(); + if (current == OpenTelemetry.noop() && hasLegacyAgentBridge()) { + // Older OTel agents bridge get(), but predate getOrNoop(). Only use get() when the + // agent has injected its API bridge. Mere agent-JAR presence is not sufficient. + current = GlobalOpenTelemetry.get(); + } + return current; + } catch (NoSuchMethodError e) { + // getOrNoop() was added in OpenTelemetry API 1.57 + getOrNoopUnavailable = true; + } + } + return GlobalOpenTelemetry.get(); + } + private boolean hasLegacyAgentBridge() { Boolean available = legacyAgentBridge; if (available == null) { From 570ac5d8749de518cb6005f484117ac82cfd802b Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 10:46:25 +0200 Subject: [PATCH 12/50] feat(ffe): Clean up config --- .../api/featureflag/config/FeatureFlaggingConfig.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index f9ad17454f9..d3afe4dbbd8 100644 --- 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 @@ -1,5 +1,8 @@ package datadog.trace.api.featureflag.config; +import static java.lang.Boolean.FALSE; +import static java.util.Locale.ROOT; + /** * The Feature Flags settings the agent needs: the product switch and the configuration source. The * {@code dd-openfeature} SDK resolves them with the same semantics, see {@code @@ -28,7 +31,7 @@ public static Resolution resolveConfiguration( final String explicitSource, final Boolean legacyProviderEnabled) { final String normalizedSource = normalizeConfigurationSource(explicitSource); - if (Boolean.FALSE.equals(providerEnabled)) { + if (FALSE.equals(providerEnabled)) { return new Resolution(false, normalizedSource); } if (normalizedSource != null) { @@ -57,7 +60,7 @@ private static String normalizeConfigurationSource(final String source) { if (source == null) { return null; } - final String normalized = source.trim().toLowerCase(java.util.Locale.ROOT); + final String normalized = source.trim().toLowerCase(ROOT); return normalized.isEmpty() ? null : normalized; } From 12276914e09514c4459d3c34a7249bf15b8c931b Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 10:46:40 +0200 Subject: [PATCH 13/50] feat(doc): Update ARCHITECTURE.md --- ARCHITECTURE.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1b67fae9ceb..985b323d8c6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,8 @@ to `.classdata` to prevent unintended loading. See [`docs/how_to_work_with_gradl - **`agent-tooling/`** — Instrumentation framework. Key types: - `InstrumenterModule` — Base class for all instrumentation modules. Declares a target system - (Tracing, AppSec, IAST, Profiling, CiVisibility, USM, etc.) and one or more instrumentations. + (Tracing, AppSec, IAST, Profiling, CiVisibility, USM, FeatureFlags, etc.) and one or more + instrumentations. - `Instrumenter` — Type matching interface: `ForSingleType`, `ForKnownTypes`, `ForTypeHierarchy`, `ForBootstrap`. - `muzzle/` — Build-time and runtime safety checks. Verifies that expected types and methods @@ -229,9 +230,8 @@ Current products: - `metrics/` — StatsD client and monitoring abstraction. Provides `Monitoring` interface with counters, timers, and histograms for internal agent metrics collection. -- `feature-flagging/` — Server-side feature flag evaluation driven by remote configuration. - Implements the OpenFeature SDK, handles the Unified Feature Control (UFC) protocol, - and tracks flag exposure per user/session. +- `feature-flagging/` — Agent services for the [`dd-openfeature/`](#dd-openfeature) SDK: + remote configuration, event delivery through the Datadog Agent, and span enrichment. ### `communication/` @@ -279,6 +279,12 @@ that implements the `io.opentracing.Tracer` interface by wrapping the Datadog `C This is a pure library for manual instrumentation only — there is no auto-instrumentation or bytecode advice. +### `dd-openfeature/` + +Feature Flags OpenFeature provider. Publishes a standalone JAR artifact (`dd-openfeature.jar`, Java 11+) +that works without the agent. When the agent is attached, an instrumentation connects it to the +`products/feature-flagging/` agent services. + ### `dd-smoke-tests/` End-to-end smoke tests. Each boots a real application with the agent jar and verifies traces, spans, From 8410be59d01c686cfddb10ae7657a2709f41ff88 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 11:32:08 +0200 Subject: [PATCH 14/50] chore(ffe): Clean up code --- .../delivery/DirectIntakeTransport.java | 10 +++++----- .../openfeature/internal/delivery/Headers.java | 1 - .../com/datadog/featureflag/EventProxy.java | 17 +++++++++-------- .../featureflag/RemoteConfigServiceImpl.java | 15 ++++++++------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java index ef328696efe..3d63e5e295c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java @@ -1,5 +1,8 @@ package com.datadog.openfeature.internal.delivery; +import static java.net.http.HttpClient.Redirect.NEVER; +import static java.net.http.HttpRequest.BodyPublishers.ofByteArray; + import com.datadog.openfeature.internal.config.Settings; import com.datadog.openfeature.internal.connector.EventTransport; import java.io.IOException; @@ -52,10 +55,7 @@ public static DirectIntakeTransport create(final Settings settings) { return new DirectIntakeTransport( URI.create("https://event-platform-intake." + settings.site() + "/api/v2/"), apiKey, - HttpClient.newBuilder() - .connectTimeout(REQUEST_TIMEOUT) - .followRedirects(HttpClient.Redirect.NEVER) - .build(), + HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).followRedirects(NEVER).build(), TimeUnit.MILLISECONDS::sleep); } @@ -69,7 +69,7 @@ public void post(final String route, final byte[] json) throws IOException { .header("x-datadog-trace-id", this.traceId) .header("x-datadog-parent-id", this.traceId); Headers.addMetadata(builder); - final HttpRequest request = builder.POST(HttpRequest.BodyPublishers.ofByteArray(json)).build(); + final HttpRequest request = builder.POST(ofByteArray(json)).build(); IOException failure = null; long delayMillis = INITIAL_RETRY_DELAY_MILLIS; for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java index 17cc71069a6..e61ab4735a0 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Headers.java @@ -16,7 +16,6 @@ private Headers() {} * * @param request the request to add the headers to. */ - // TODO Add the Datadog-Container-ID and Datadog-Entity-ID headers once the SDK detects them. static void addMetadata(final HttpRequest.Builder request) { request .header("Datadog-Meta-Lang", "java") diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java index 66a168474d8..ccc28f1e858 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/EventProxy.java @@ -1,10 +1,13 @@ package com.datadog.featureflag; +import static com.datadog.featureflag.FeatureFlagEventType.EXPOSURE; +import static com.datadog.featureflag.FeatureFlagEventType.FLAG_EVALUATION; +import static datadog.communication.http.HttpRetryPolicy.Factory.NEVER_RETRY; +import static datadog.trace.api.intake.Intake.EVENT_PLATFORM; + import datadog.communication.BackendApi; import datadog.communication.BackendApiFactory; import datadog.communication.HttpResponseException; -import datadog.communication.http.HttpRetryPolicy; -import datadog.trace.api.intake.Intake; import java.io.IOException; import java.net.ConnectException; import javax.annotation.Nullable; @@ -73,8 +76,8 @@ private void resolve() { if (this.resolved) { return; } - this.exposures = create(FeatureFlagEventType.EXPOSURE); - this.flagEvaluations = create(FeatureFlagEventType.FLAG_EVALUATION); + this.exposures = create(EXPOSURE); + this.flagEvaluations = create(FLAG_EVALUATION); this.resolved = true; } } @@ -83,11 +86,9 @@ private void resolve() { private BackendApi create(final FeatureFlagEventType eventType) { return this.directFallbackAvailable ? this.backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, - eventType.responseCompressionEnabled(), - HttpRetryPolicy.Factory.NEVER_RETRY) + EVENT_PLATFORM, eventType.responseCompressionEnabled(), NEVER_RETRY) : this.backendApiFactory.createEvpProxyApi( - Intake.EVENT_PLATFORM, eventType.responseCompressionEnabled()); + EVENT_PLATFORM, eventType.responseCompressionEnabled()); } static boolean isDefinitiveRejection(final IOException exception) { diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java index cdfdc72278b..f540d423e0b 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/RemoteConfigServiceImpl.java @@ -1,10 +1,11 @@ package com.datadog.featureflag; -import datadog.remoteconfig.Capabilities; +import static datadog.remoteconfig.Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES; +import static datadog.remoteconfig.Product.FFE_FLAGS; + import datadog.remoteconfig.ConfigurationChangesTypedListener; import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.PollingRateHinter; -import datadog.remoteconfig.Product; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; @@ -37,8 +38,9 @@ public synchronized void start() { return; } this.started = true; - this.configurationPoller.addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - this.configurationPoller.addListener(Product.FFE_FLAGS, content -> content, this); + this.configurationPoller.addCapabilities(CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + // Listen as serialized event to prevent depending on the deserializer from agent + this.configurationPoller.addListener(FFE_FLAGS, content -> content, this); this.configurationPoller.start(); } @@ -64,9 +66,8 @@ public synchronized void close() { return; } this.started = false; - this.configurationPoller.removeCapabilities( - Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); - this.configurationPoller.removeListeners(Product.FFE_FLAGS); + this.configurationPoller.removeCapabilities(CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + this.configurationPoller.removeListeners(FFE_FLAGS); this.subscribers.clear(); this.current = null; } From c5fcf2210ff1ab6ae5add55df0cd01fcda68ac2c Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 11:38:26 +0200 Subject: [PATCH 15/50] feat(ffe): Port benchmarks --- dd-openfeature/build.gradle.kts | 8 +- .../FlagEvalHookHotPathBenchmark.java | 188 +++++++++++++++++ ...gEvaluationEnqueueContentionBenchmark.java | 189 ++++++++++++++++++ .../FlagEvaluationHotPathBenchmark.java | 160 +++++++++++++++ .../openfeature/FlagEvalLoggingHook.java | 8 +- .../com/datadog/openfeature/Provider.java | 4 +- .../FlagEvaluationPipeline.java | 25 +-- .../flagevaluation/FlagEvaluationWriter.java | 33 +++ .../openfeature/FlagEvalLoggingHookTest.java | 26 ++- 9 files changed, 598 insertions(+), 43 deletions(-) create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java create mode 100644 dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts index 5fd05d67d73..a0a715a005b 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -6,6 +6,7 @@ plugins { idea id("dd-trace-java.module.distributable.api") id("dd-trace-java.version-file") + id("dd-trace-java.jmh-conventions") } description = "Datadog OpenFeature provider SDK." @@ -38,9 +39,6 @@ java { } } -// TODO Port the evaluation hot path JMH benchmarks removed with the former feature-flagging-api and -// feature-flagging-lib modules (FlagEvalHookHotPathBenchmark, FlagEvaluationHotPathBenchmark and -// FlagEvaluationEnqueueContentionBenchmark). dependencies { api("dev.openfeature:sdk:1.22.0") api("io.opentelemetry:opentelemetry-api:1.66.0") @@ -50,6 +48,10 @@ dependencies { testImplementation(libs.bundles.mockito) } +jmh { + jmhVersion = libs.versions.jmh.get() +} + tasks.withType().configureEach { configureCompiler(JavaVersion.VERSION_11) } diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java new file mode 100644 index 00000000000..34412974120 --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java @@ -0,0 +1,188 @@ +package com.datadog.openfeature; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ImmutableStructure; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Evaluation-thread benchmark for the OpenFeature hook: the inline cost a caller actually pays + * inside FlagEvalLoggingHook.finallyAfter, across context shapes. + * + *

    This is the counterpart to FlagEvaluationHotPathBenchmark, which measures the writer queue and + * the worker-thread aggregation. + * + *

    The dominant inline cost under consent-on is DDEvaluator.copyPrunedContext: one bounded walk + * of the caller-owned EvaluationContext. contextCopy isolates that walk so its share of + * hookFinallyAfter is directly readable. Under consent-off the hook skips the copy entirely, which + * hookFinallyAfterConsentOff measures as the protected-path floor. + * + *

    The writer used here is a no-op that discards events, so no queue or worker cost is included. + * + *

    Run: {@code ./gradlew :dd-openfeature:jmh -Pjmh.includes=FlagEvalHookHotPathBenchmark}. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 1) +public class FlagEvalHookHotPathBenchmark { + + /** + * Context shapes. The three 100-leaf shapes (flat/100attrs, nested/10structs_10fields, + * list/10lists_10items) carry the same leaf count under different structure, so the spread + * between them isolates shape cost from leaf count. + */ + @Param({ + "flat/0attrs", + "flat/10attrs", + "flat/100attrs", + "nested/10structs_10fields", + "list/10lists_10items" + }) + public String shape; + + private HookContext hookContext; + private FlagEvaluationDetails consentOnDetails; + private FlagEvaluationDetails consentOffDetails; + private FlagEvalLoggingHook hook; + + @Setup(Level.Trial) + public void setUp() { + final MutableContext ctx = buildContext(shape); + hookContext = + HookContext.builder() + .flagKey("bench-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(ctx) + .build(); + + consentOnDetails = details(true); + consentOffDetails = details(false); + + // Discarding writer: isolates hook-inline cost from queue mechanics. + final FlagEvaluationWriter writer = new NoOpWriter(); + hook = new FlagEvalLoggingHook<>(() -> writer); + } + + /** Total inline cost under consent-on: scalar extraction, bounded context copy, and enqueue. */ + @Benchmark + public void hookFinallyAfter() { + hook.finallyAfter(hookContext, consentOnDetails, Collections.emptyMap()); + } + + /** Protected-path floor: consent-off skips the context copy, leaving scalar work plus enqueue. */ + @Benchmark + public void hookFinallyAfterConsentOff() { + hook.finallyAfter(hookContext, consentOffDetails, Collections.emptyMap()); + } + + /** The bounded context copy alone - the component that scales with context shape. */ + @Benchmark + public void contextCopy(final Blackhole blackhole) { + blackhole.consume(DDEvaluator.copyPrunedContext(hookContext.getCtx())); + } + + private static FlagEvaluationDetails details(final boolean observeFullEvaluationData) { + return FlagEvaluationDetails.builder() + .flagKey("bench-flag") + .value("on-value") + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata( + ImmutableMetadata.builder() + .addString("allocationKey", "alloc-1") + .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) + .addBoolean( + DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build()) + .build(); + } + + private static MutableContext buildContext(final String shape) { + final MutableContext ctx = new MutableContext("bench-user"); + if ("flat/0attrs".equals(shape)) { + return ctx; + } + if ("flat/10attrs".equals(shape)) { + return addFlat(ctx, 10); + } + if ("flat/100attrs".equals(shape)) { + return addFlat(ctx, 100); + } + if ("nested/10structs_10fields".equals(shape)) { + for (int i = 0; i < 10; i++) { + final Map inner = new HashMap<>(); + for (int j = 0; j < 10; j++) { + inner.put("field" + j, new Value("value" + j)); + } + ctx.add("struct" + i, new ImmutableStructure(inner)); + } + return ctx; + } + if ("list/10lists_10items".equals(shape)) { + for (int i = 0; i < 10; i++) { + final List items = new ArrayList<>(10); + for (int j = 0; j < 10; j++) { + items.add(new Value("value" + j)); + } + ctx.add("list" + i, items); + } + return ctx; + } + throw new IllegalArgumentException("unknown benchmark shape: " + shape); + } + + private static MutableContext addFlat(final MutableContext ctx, final int count) { + for (int i = 0; i < count; i++) { + ctx.add("field" + i, "value" + i); + } + return ctx; + } + + /** Discards events so only hook-inline work is measured. */ + private static final class NoOpWriter implements FlagEvaluationWriter { + @Override + public void enqueue(final FlagEvalEvent event) {} + + @Override + public boolean hasCapacityForEnqueue() { + return true; + } + + @Override + public void countPreQueueOverflow() {} + + @Override + public void countContextTruncated(final String reason) {} + } +} diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java new file mode 100644 index 00000000000..b862c1388f9 --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java @@ -0,0 +1,189 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.util.HashMap; +import java.util.Map; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Group; +import org.openjdk.jmh.annotations.GroupThreads; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Producer-contention benchmark for {@link FlagEvaluationPipeline#enqueue}. + * + *

    Every flag evaluation in every application thread calls {@code enqueue}, so producer-side + * scaling is the property that matters. {@link FlagEvaluationHotPathBenchmark#writerEnqueue} + * measures the single-threaded cost and cannot see contention; this benchmark measures how enqueue + * behaves as producer count grows. + * + *

    Why groups rather than {@code @Threads}: the hand-off queue ({@code + * EventQueue}) is multi-producer, single-consumer. Calling {@code poll()} from several + * threads breaks that contract, so producer threads cannot drain their own events. Each group + * therefore pairs N producers with exactly one consumer. + * + *

    Why the consumer drains in batches: with more producers than consumers the + * queue would otherwise saturate, after which {@code offer} fast-fails and the measurement turns + * into {@code AtomicLong} contention on the overflow counter instead of the enqueue path. The + * consumer polls up to {@link #DRAIN_BATCH} events per invocation to keep the queue off its + * capacity limit. Overflow is reported at the end of each iteration so a saturated - and therefore + * invalid - run is visible rather than silent. + * + *

    Run: {@code ./gradlew :dd-openfeature:jmh + * -Pjmh.includes=FlagEvaluationEnqueueContentionBenchmark}. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 1) +public class FlagEvaluationEnqueueContentionBenchmark { + + /** Events the single consumer drains per invocation, so it can keep up with N producers. */ + static final int DRAIN_BATCH = 32; + + private static final int NUM_FLAGS = 100; + private static final int NUM_USERS = 50; + private static final int NUM_FIELDS = 10; + + private Map attrs; + private String[] flagKeys; + private String[] targetingKeys; + private FlagEvaluationPipeline writer; + + /** Per-producer cursor: a shared counter would add its own cache-line contention. */ + @State(Scope.Thread) + public static class ProducerCursor { + int cursor; + } + + @Setup(Level.Iteration) + public void setUp() { + attrs = new HashMap<>(); + for (int i = 0; i < NUM_FIELDS; i++) { + attrs.put("field" + i, "value"); + } + flagKeys = keys("bench-flag-", NUM_FLAGS); + targetingKeys = keys("bench-user-", NUM_USERS); + + // Capacity well above what the batch-draining consumer should ever let build up. + writer = + new FlagEvaluationPipeline( + 1 << 20, + Long.MAX_VALUE, + NANOSECONDS, + (route, json) -> {}, + new HashMap<>(), + new RuntimeServices(HealthMetrics.NOOP)); + } + + /** + * Reports queue overflow so a saturated run - where the consumer failed to keep up and the + * numbers no longer describe the enqueue path - is visible in the benchmark output. + */ + @org.openjdk.jmh.annotations.TearDown(Level.Iteration) + @SuppressForbidden + public void reportOverflow() { + final long dropped = writer.droppedQueueOverflow(); + if (dropped > 0) { + System.out.println( + "\nWARNING: queue overflowed " + + dropped + + " times - consumer could not keep up, enqueue timings for this iteration are" + + " measuring overflow accounting, not the enqueue path."); + } + } + + // ---- 1 producer: uncontended baseline ---- + + @Benchmark + @Group("producers1") + @GroupThreads(1) + public void enqueue1(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); + } + + @Benchmark + @Group("producers1") + @GroupThreads(1) + public void drain1(final Blackhole blackhole) { + drain(blackhole); + } + + // ---- 4 producers ---- + + @Benchmark + @Group("producers4") + @GroupThreads(4) + public void enqueue4(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); + } + + @Benchmark + @Group("producers4") + @GroupThreads(1) + public void drain4(final Blackhole blackhole) { + drain(blackhole); + } + + // ---- 16 producers ---- + + @Benchmark + @Group("producers16") + @GroupThreads(16) + public void enqueue16(final ProducerCursor c) { + writer.enqueue(nextEvent(c)); + } + + @Benchmark + @Group("producers16") + @GroupThreads(1) + public void drain16(final Blackhole blackhole) { + drain(blackhole); + } + + private void drain(final Blackhole blackhole) { + for (int i = 0; i < DRAIN_BATCH; i++) { + final FlagEvalEvent event = writer.pollQueuedEventForTest(); + if (event == null) { + break; + } + blackhole.consume(event); + } + } + + private FlagEvalEvent nextEvent(final ProducerCursor c) { + final int i = c.cursor++; + return new FlagEvalEvent( + flagKeys[Math.floorMod(i, flagKeys.length)], + "variant-" + Math.floorMod(i, 4), + "alloc-" + Math.floorMod(i, flagKeys.length), + targetingKeys[Math.floorMod(i, targetingKeys.length)], + null, + 1_700_000_000_000L + i, + attrs); + } + + private static String[] keys(final String prefix, final int count) { + final String[] out = new String[count]; + for (int i = 0; i < count; i++) { + out[i] = prefix + i; + } + return out; + } +} diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java new file mode 100644 index 00000000000..6a1f6154b2f --- /dev/null +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java @@ -0,0 +1,160 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import java.util.HashMap; +import java.util.Map; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Writer-side benchmark for EVP {@code flagevaluation} recording: the bounded-queue enqueue and the + * worker-thread aggregation of already-captured events. + * + *

    Scope limitation: this benchmark starts from a pre-built FlagEvalEvent + * holding a ready-made flat attribute map. It therefore does NOT cover the OpenFeature hook's own + * inline capture cost - in particular the bounded evaluation-context copy that + * FlagEvalLoggingHook.finallyAfter performs synchronously on the evaluation thread under + * consent-on. That cost is measured separately by FlagEvalHookHotPathBenchmark in the root package, + * where the hook and the OpenFeature context types live. Do not read writerEnqueue as the total + * per-evaluation cost paid by the caller. + * + *

    Events are built with observeFullEvaluationData=true so the aggregator actually canonicalizes + * the context. Under the consent-off default it drops attrs and skips canonicalization, which makes + * every field-count profile measure the same scalar-only work. + * + *

    Run: {@code ./gradlew :dd-openfeature:jmh -Pjmh.includes=FlagEvaluationHotPathBenchmark}. + */ +@State(Scope.Benchmark) +@Warmup(iterations = 3, time = 2, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(NANOSECONDS) +@Fork(value = 1) +public class FlagEvaluationHotPathBenchmark { + + @Param({ + "typical/100flags_50users_10fields", + "stress/10flags_1000users_250fields", + "scale/2500flags_500users_20fields" + }) + public String profile; + + private Map attrs; + private String[] flagKeys; + private String[] targetingKeys; + private int cursor; + private FlagEvaluationPipeline writer; + private FlagEvaluationPipeline.SerializingHandlerForTest handler; + + @Setup(Level.Iteration) + public void setUp() { + final Profile p = Profile.fromName(profile); + + attrs = new HashMap<>(); + for (int i = 0; i < p.numFields; i++) { + attrs.put("field" + i, "value"); + } + flagKeys = keys("bench-flag-", p.numFlags); + targetingKeys = keys("bench-user-", p.numUsers); + cursor = 0; + + final EventTransport transport = (route, json) -> {}; + final RuntimeServices services = new RuntimeServices(HealthMetrics.NOOP); + final Map ddContext = new HashMap<>(); + ddContext.put("service", "bench-service"); + handler = new FlagEvaluationPipeline.SerializingHandlerForTest(transport, ddContext, services); + + // Capacity large enough that the benchmark never overflows within a measurement window. + writer = + new FlagEvaluationPipeline( + 1 << 20, Long.MAX_VALUE, NANOSECONDS, transport, ddContext, services); + } + + /** + * Queue-mechanics cost only: event allocation plus the non-blocking bounded-queue offer. Excludes + * the hook's inline context snapshot - see the class javadoc. + */ + @Benchmark + public void writerEnqueue(final Blackhole blackhole) { + final FlagEvalEvent event = nextEvent(); + writer.enqueue(event); + blackhole.consume(writer.pollQueuedEventForTest()); + blackhole.consume(event); + } + + /** Worker-thread cost: materialize context, prune, canonicalize, and aggregate. */ + @Benchmark + public void workerAggregate(final Blackhole blackhole) { + final FlagEvalEvent event = nextEvent(); + handler.aggregateEvent(event); + if ((cursor % 10_000) == 0) { + handler.clearAggregationForTest(); + } + blackhole.consume(handler.fullTierSizeForTest()); + } + + private FlagEvalEvent nextEvent() { + final int i = cursor++; + // observeFullEvaluationData=true is required for this benchmark to mean anything: under the + // consent-off default the aggregator drops attrs and skips canonicalContextKey entirely, so + // every field-count profile would collapse to the same scalar-only cost. + return new FlagEvalEvent( + flagKeys[Math.floorMod(i, flagKeys.length)], + "variant-" + Math.floorMod(i, 4), + "alloc-" + Math.floorMod(i, flagKeys.length), + targetingKeys[Math.floorMod(i, targetingKeys.length)], + null, + 1_700_000_000_000L + i, + true, + attrs); + } + + private static String[] keys(final String prefix, final int count) { + final String[] out = new String[count]; + for (int i = 0; i < count; i++) { + out[i] = prefix + i; + } + return out; + } + + private static final class Profile { + private final int numFlags; + private final int numUsers; + private final int numFields; + + private Profile(final int numFlags, final int numUsers, final int numFields) { + this.numFlags = numFlags; + this.numUsers = numUsers; + this.numFields = numFields; + } + + private static Profile fromName(final String name) { + if ("typical/100flags_50users_10fields".equals(name)) { + return new Profile(100, 50, 10); + } + if ("stress/10flags_1000users_250fields".equals(name)) { + return new Profile(10, 1_000, 250); + } + if ("scale/2500flags_500users_20fields".equals(name)) { + return new Profile(2_500, 500, 20); + } + throw new IllegalArgumentException("unknown benchmark profile: " + name); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java index 2dafb6d89eb..5baab2df44a 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -1,7 +1,7 @@ package com.datadog.openfeature; import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; -import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.HookContext; @@ -33,9 +33,9 @@ class FlagEvalLoggingHook implements Hook { /** Pipeline resolver, returning {@code null} when evaluation counts are not delivered. */ - private final Supplier writerSupplier; + private final Supplier writerSupplier; - FlagEvalLoggingHook(final Supplier writerSupplier) { + FlagEvalLoggingHook(final Supplier writerSupplier) { this.writerSupplier = writerSupplier; } @@ -55,7 +55,7 @@ public void finallyAfter( if (details == null) { return; } - final FlagEvaluationPipeline w = writerSupplier.get(); + final FlagEvaluationWriter w = writerSupplier.get(); if (w == null) { return; } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java index 4aff66593ed..62f1ab751e5 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -6,7 +6,7 @@ import com.datadog.openfeature.internal.config.Settings; import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.connector.Connectors; -import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; @@ -227,7 +227,7 @@ private Evaluator buildEvaluator() { return new DDEvaluator(this::onConfigurationChange, this.connector, this.settings); } - private FlagEvaluationPipeline flagEvaluationPipeline() { + private FlagEvaluationWriter flagEvaluationPipeline() { final Evaluator current = this.evaluator; if (!(current instanceof DDEvaluator)) { return null; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java index 50fb86dbd29..8888f6331ba 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java @@ -38,7 +38,7 @@ * the worker has been joined, counting any remainder as a closed drop so shutdown loss is * observable rather than silent. */ -public class FlagEvaluationPipeline implements AutoCloseable { +public class FlagEvaluationPipeline implements FlagEvaluationWriter, AutoCloseable { private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluationPipeline.class); @@ -168,12 +168,7 @@ private void sweepAndCountResidualEvents() { countMetric(FLAG_EVALUATION_DROPPED_METRIC, residual, DROP_REASON_CLOSED); } - /** - * Non-blocking enqueue of a flag evaluation event. Drops the event, and counts the drop, if the - * queue is full. - * - * @param event the event to enqueue. - */ + @Override public void enqueue(final FlagEvalEvent event) { if (event == null) { return; @@ -200,27 +195,17 @@ public void enqueue(final FlagEvalEvent event) { } } - /** - * Reports whether the async hand-off queue currently has room for another event. Producers - * consult this before performing any expensive context-copy work so a saturated queue is observed - * as an O(1) read rather than a full snapshot followed by a discarded offer. Best-effort only: - * the worker can drain (or a peer producer can fill) between this check and the subsequent - * enqueue, so callers must still tolerate offer failure. - */ + @Override public boolean hasCapacityForEnqueue() { return queue.size() < queue.capacity(); } - /** Counts one queue-overflow drop without offering an event. */ + @Override public void countPreQueueOverflow() { droppedQueueOverflow.incrementAndGet(); } - /** - * Counts one evaluation whose context was truncated. - * - * @param reason the sorted, comma-separated set of the caps that fired. - */ + @Override public void countContextTruncated(final String reason) { contextTruncatedCounts.computeIfAbsent(reason, k -> new AtomicLong(0)).incrementAndGet(); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java new file mode 100644 index 00000000000..5733ef94d34 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationWriter.java @@ -0,0 +1,33 @@ +package com.datadog.openfeature.internal.flagevaluation; + +/** Records flag evaluation events from the evaluation thread, without blocking. */ +public interface FlagEvaluationWriter { + /** + * Reports whether the async hand-off queue currently has room for another event. Producers + * consult this before performing any expensive context-copy work so a saturated queue is observed + * as an O(1) read rather than a full snapshot followed by a discarded offer. Best-effort only: + * the worker can drain (or a peer producer can fill) between this check and the subsequent + * enqueue, so callers must still tolerate offer failure. + * + * @return {@code true} if the queue currently has room for another event. + */ + boolean hasCapacityForEnqueue(); + + /** Counts one queue-overflow drop without offering an event. */ + void countPreQueueOverflow(); + + /** + * Counts one evaluation whose context was truncated. + * + * @param reason the sorted, comma-separated set of the caps that fired. + */ + void countContextTruncated(String reason); + + /** + * Non-blocking enqueue of a flag evaluation event. Drops the event, and counts the drop, if the + * queue is full. + * + * @param event the event to enqueue. + */ + void enqueue(FlagEvalEvent event); +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java index 63bcdd23519..32eb936c932 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java @@ -13,10 +13,11 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; -import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; @@ -43,8 +44,8 @@ class FlagEvalLoggingHookTest { // ---- helpers ---- /** Creates a pipeline mock that captures the enqueued event for assertion. */ - private static FlagEvaluationPipeline capturingWriter(final AtomicReference ref) { - final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); + private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); when(writer.hasCapacityForEnqueue()).thenReturn(true); doAnswer( invocation -> { @@ -56,7 +57,7 @@ private static FlagEvaluationPipeline capturingWriter(final AtomicReference hookWithWriter(final FlagEvaluationPipeline writer) { + private static FlagEvalLoggingHook hookWithWriter(final FlagEvaluationWriter writer) { return new FlagEvalLoggingHook<>(() -> writer); } @@ -329,7 +330,7 @@ void successPathHasNullErrorMessage() { @Test void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { - final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); when(writer.hasCapacityForEnqueue()).thenReturn(true); final FlagEvalLoggingHook hook = hookWithWriter(writer); @@ -338,18 +339,15 @@ void finallyAfterOnlyCallsEnqueueAndCapacityCheckOnWriter() { hook.finallyAfter(null, det, Collections.emptyMap()); - // Capacity check gates the enqueue; exactly one enqueue call; no start/close. + // Capacity check gates the enqueue; exactly one enqueue call; nothing else. verify(writer, times(1)).hasCapacityForEnqueue(); verify(writer, times(1)).enqueue(any(FlagEvalEvent.class)); - verify(writer, never()).countPreQueueOverflow(); - verify(writer, never()).countContextTruncated(any()); - verify(writer, never()).close(); - verify(writer, never()).start(); + verifyNoMoreInteractions(writer); } @Test void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { - final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); when(writer.hasCapacityForEnqueue()).thenReturn(false); final FlagEvalLoggingHook hook = hookWithWriter(writer); @@ -366,7 +364,7 @@ void finallyAfterSkipsEnqueueAndCountsDropWhenQueueSaturated() { @Test void countContextTruncatedCalledWhenContextIsTruncated() { - final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); when(writer.hasCapacityForEnqueue()).thenReturn(true); final FlagEvalLoggingHook hook = hookWithWriter(writer); @@ -398,7 +396,7 @@ void countContextTruncatedCalledWhenContextIsTruncated() { @Test void countContextTruncatedNotCalledWhenNoTruncation() { - final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); when(writer.hasCapacityForEnqueue()).thenReturn(true); final FlagEvalLoggingHook hook = hookWithWriter(writer); @@ -454,7 +452,7 @@ void writerLookupLinkageErrorIsNoOp() { @Test void detailsNullIsNoOp() { - final FlagEvaluationPipeline writer = mock(FlagEvaluationPipeline.class); + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); final FlagEvalLoggingHook hook = hookWithWriter(writer); // Should not throw From 8eed26a602d42cf28e3be48b27ced14282b7e9f2 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 14:02:06 +0200 Subject: [PATCH 16/50] fix(ffe): Fix failed start leaving broken runtime --- .../internal/FeatureFlagsRuntime.java | 5 ++-- .../internal/FeatureFlagsRuntimeTest.java | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java index f4ed6236c0f..57896c89f7f 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -62,8 +62,9 @@ public final class FeatureFlagsRuntime { public static FeatureFlagsRuntime acquire(final Connector connector, final Settings settings) { synchronized (SHARED_LOCK) { if (shared == null) { - shared = create(connector, settings); - shared.start(); + final FeatureFlagsRuntime created = create(connector, settings); + created.start(); + shared = created; } references++; return shared; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java index 685d47ddece..bc8545cd387 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -149,6 +149,32 @@ void sharedRuntimeIsReferenceCounted() { FeatureFlagsRuntime.release(); } + @Test + void failedStartDoesNotCacheTheSharedRuntime() { + final ConfigurationSource failing = + listener -> { + throw new IllegalStateException("subscription failed"); + }; + assertThrows( + IllegalStateException.class, + () -> + FeatureFlagsRuntime.acquire( + new TestConnector(failing, null), + TestSettings.of("feature.flags.configuration.source", "remote_config"))); + + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.acquire( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + try { + assertNotNull(runtime); + assertNotNull(source.listener, "a later acquisition starts a new runtime"); + } finally { + FeatureFlagsRuntime.release(); + } + } + @Test void recordsExposuresOnlyWhenDeliveryIsEnabled() { final FeatureFlagsRuntime runtime = From beadf463b53445660e9b37d1f92928576cd4b8e3 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 14:02:25 +0200 Subject: [PATCH 17/50] fix(ffe): Fix no rollback when backend start fails --- .../featureflag/FeatureFlaggingSystem.java | 7 ++++- .../FeatureFlaggingSystemTest.java | 29 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) 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 c723be3e2f9..0b971d3f6f0 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 @@ -34,7 +34,12 @@ public static synchronized void start(final SharedCommunicationObjects sco) { return; } final FeatureFlagsBackend backend = new FeatureFlagsBackend(sco, config); - backend.start(); + try { + backend.start(); + } catch (final RuntimeException | Error e) { + backend.close(); + throw e; + } BACKEND = backend; FeatureFlaggingGateway.register(backend); LOGGER.debug("Feature Flagging system started"); diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java index f3c2ff98f8b..5e6cbfb5d48 100644 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java +++ b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java @@ -1,17 +1,28 @@ package com.datadog.featureflag; +import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIGURATION_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.remoteconfig.Capabilities; +import datadog.remoteconfig.ConfigurationPoller; +import datadog.remoteconfig.Product; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.test.junit.utils.config.WithConfig; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +@WithConfig(key = FEATURE_FLAGS_ENABLED, value = "true") class FeatureFlaggingSystemTest { @AfterEach void stop() { @@ -19,7 +30,6 @@ void stop() { } @Test - @WithConfig(key = FEATURE_FLAGS_ENABLED, value = "true") void startRegistersTheBackendAndStopUnregistersIt() { FeatureFlaggingSystem.start(mock(SharedCommunicationObjects.class)); assertInstanceOf(FeatureFlagsBackend.class, FeatureFlaggingGateway.backend()); @@ -29,7 +39,6 @@ void startRegistersTheBackendAndStopUnregistersIt() { } @Test - @WithConfig(key = FEATURE_FLAGS_ENABLED, value = "true") void startIsIdempotent() { final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); FeatureFlaggingSystem.start(sco); @@ -40,6 +49,22 @@ void startIsIdempotent() { assertSame(backend, FeatureFlaggingGateway.backend()); } + @Test + @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") + @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") + void failedStartRollsBackTheRemoteConfigRegistration() { + final SharedCommunicationObjects sco = mock(SharedCommunicationObjects.class); + final ConfigurationPoller poller = mock(ConfigurationPoller.class); + when(sco.configurationPoller(any())).thenReturn(poller); + doThrow(new IllegalStateException("poller failed")).when(poller).start(); + + assertThrows(IllegalStateException.class, () -> FeatureFlaggingSystem.start(sco)); + + assertNull(FeatureFlaggingGateway.backend()); + verify(poller).removeCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); + verify(poller).removeListeners(Product.FFE_FLAGS); + } + @Test @WithConfig(key = FEATURE_FLAGS_ENABLED, value = "false") void startDoesNothingWhenFeatureFlagsIsDisabled() { From 5c03ef05c1085e44516ef5b7725a2616f753ddfb Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 17:27:30 +0200 Subject: [PATCH 18/50] fix(ffe): Fix config on agent initialization --- .../java/datadog/trace/bootstrap/Agent.java | 44 +------------------ .../main/java/datadog/trace/api/Config.java | 6 +-- 2 files changed, 4 insertions(+), 46 deletions(-) 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 522b7a0e117..b477468fc63 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 @@ -44,7 +44,6 @@ 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; @@ -195,7 +194,7 @@ public boolean isEnabledByDefault() { private static boolean distributedDebuggerEnabled = false; private static boolean agentlessLogSubmissionEnabled = false; private static boolean appLogsCollectionEnabled = false; - private static boolean featureFlaggingEnabled = false; + private static volatile boolean featureFlaggingEnabled = false; private static void safelySetContextClassLoader(ClassLoader classLoader) { try { @@ -284,7 +283,6 @@ public static void start( agentlessLogSubmissionEnabled = isFeatureEnabled(AgentFeature.AGENTLESS_LOG_SUBMISSION); appLogsCollectionEnabled = isFeatureEnabled(AgentFeature.APP_LOGS_COLLECTION); llmObsEnabled = isFeatureEnabled(AgentFeature.LLMOBS); - featureFlaggingEnabled = isFeatureFlaggingEnabled(); // setup writers when llmobs is enabled to accomodate apm and llmobs if (llmObsEnabled) { @@ -1278,6 +1276,7 @@ private static void maybeStartLLMObs(Instrumentation inst, Class scoClass, Ob } private static void maybeStartFeatureFlagging(final Class scoClass, final Object sco) { + featureFlaggingEnabled = Config.get().isFeatureFlaggingProviderEnabled(); if (featureFlaggingEnabled) { StaticEventLogger.begin("Feature Flagging"); @@ -1796,45 +1795,6 @@ private static boolean isFeatureEnabled(AgentFeature feature) { } } - private static boolean isFeatureFlaggingEnabled() { - final Boolean providerEnabled = - featureFlaggingBooleanSetting(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED); - final String configurationSource = - featureFlaggingSetting(FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE); - final Boolean legacyProviderEnabled = - featureFlaggingBooleanSetting(FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); - - return FeatureFlaggingConfig.resolveConfiguration( - providerEnabled, configurationSource, legacyProviderEnabled) - .isEnabled(); - } - - @SuppressFBWarnings( - value = "NP_BOOLEAN_RETURN_NULL", - justification = "A null value preserves the distinction between absent and explicitly false") - private static Boolean featureFlaggingBooleanSetting(final String configKey) { - final String value = featureFlaggingSetting(configKey); - if (value == null) { - return null; - } - return Boolean.parseBoolean(value) || "1".equals(value); - } - - private static String featureFlaggingSetting(final String configKey) { - final String systemProperty = propertyNameToSystemPropertyName(configKey); - String value = SystemProperties.get(systemProperty); - if (value == null) { - value = getStableConfig(FLEET, configKey); - } - if (value == null) { - value = ddGetEnv(systemProperty); - } - if (value == null) { - value = getStableConfig(LOCAL, configKey); - } - return value; - } - /** * @see datadog.trace.api.ProductActivation#fromString(String) */ diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 249af11be8e..1bf2a2001e1 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -1253,7 +1253,6 @@ public static String getHostName() { private final int remoteConfigMaxExtraServices; - private final boolean featureFlaggingProviderEnabled; private final String featureFlaggingConfigurationSource; private final boolean dbmInjectSqlBaseHash; @@ -2968,7 +2967,6 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) "Unsupported Feature Flagging configuration source; provider disabled: '{}'", resolvedFeatureFlaggingConfiguration.getSource()); } - featureFlaggingProviderEnabled = resolvedFeatureFlaggingConfiguration.isEnabled(); featureFlaggingConfigurationSource = resolvedFeatureFlaggingConfiguration.getSource(); dynamicInstrumentationEnabled = @@ -4903,7 +4901,7 @@ public int getRemoteConfigMaxExtraServices() { } public boolean isFeatureFlaggingProviderEnabled() { - return featureFlaggingProviderEnabled; + return instrumenterConfig.isFeatureFlaggingEnabled(); } public String getFeatureFlaggingConfigurationSource() { @@ -6867,7 +6865,7 @@ public String toString() { + ", remoteConfigIntegrityCheckEnabled=" + remoteConfigIntegrityCheckEnabled + ", featureFlaggingProviderEnabled=" - + featureFlaggingProviderEnabled + + isFeatureFlaggingProviderEnabled() + ", featureFlaggingConfigurationSource=" + featureFlaggingConfigurationSource + ", debuggerEnabled=" From 8c8dddd665d371d1c6a3935bf291b7e32b236716 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 17:29:58 +0200 Subject: [PATCH 19/50] chore: Update CODEOWNERS --- .github/CODEOWNERS | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f6bd693e0c4..5379a1aeb56 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -350,8 +350,10 @@ /dd-smoke-tests/kafka-3/ @DataDog/data-streams-monitoring @DataDog/apm-idm-java # @DataDog/feature-flagging-and-experimentation-sdk -/dd-smoke-tests/openfeature/ @DataDog/feature-flagging-and-experimentation-sdk -/products/feature-flagging/ @DataDog/feature-flagging-and-experimentation-sdk +/dd-java-agent/instrumentation/datadog/openfeature/ @DataDog/feature-flagging-and-experimentation-sdk +/dd-openfeature/ @DataDog/feature-flagging-and-experimentation-sdk +/dd-smoke-tests/openfeature/ @DataDog/feature-flagging-and-experimentation-sdk +/products/feature-flagging/ @DataDog/feature-flagging-and-experimentation-sdk # @DataDog/profiling-java /dd-java-agent/ddprof-lib/ @DataDog/profiling-java From 3db3662b3afa60c2935510b8f48908cebbe8cc38 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 17:49:21 +0200 Subject: [PATCH 20/50] chore(ffe): Remove dead fixtures --- .../resources/allocation-window-dates.json | 35 ---------- .../src/test/resources/empty-config.json | 8 --- .../test/resources/malformed-allocations.json | 45 ------------- .../src/test/resources/null-flag.json | 34 ---------- .../src/test/resources/null-flags.json | 8 --- .../src/test/resources/serial-id.json | 34 ---------- .../test/resources/sibling-serial-ids.json | 59 ---------------- .../src/test/resources/unknown-operator.json | 67 ------------------- .../resources/unknown-top-level-fields.json | 13 ---- 9 files changed, 303 deletions(-) delete mode 100644 dd-openfeature/src/test/resources/allocation-window-dates.json delete mode 100644 dd-openfeature/src/test/resources/empty-config.json delete mode 100644 dd-openfeature/src/test/resources/malformed-allocations.json delete mode 100644 dd-openfeature/src/test/resources/null-flag.json delete mode 100644 dd-openfeature/src/test/resources/null-flags.json delete mode 100644 dd-openfeature/src/test/resources/serial-id.json delete mode 100644 dd-openfeature/src/test/resources/sibling-serial-ids.json delete mode 100644 dd-openfeature/src/test/resources/unknown-operator.json delete mode 100644 dd-openfeature/src/test/resources/unknown-top-level-fields.json diff --git a/dd-openfeature/src/test/resources/allocation-window-dates.json b/dd-openfeature/src/test/resources/allocation-window-dates.json deleted file mode 100644 index 26d0a90da2a..00000000000 --- a/dd-openfeature/src/test/resources/allocation-window-dates.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "dated-flag": { - "key": "dated-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "dated-allocation", - "rules": [], - "startAt": "2023-01-01T01:00:00.123456+01:00", - "endAt": "2023-01-02T00:00:00.987654Z", - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/dd-openfeature/src/test/resources/empty-config.json b/dd-openfeature/src/test/resources/empty-config.json deleted file mode 100644 index 51b021e5de7..00000000000 --- a/dd-openfeature/src/test/resources/empty-config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": {} -} diff --git a/dd-openfeature/src/test/resources/malformed-allocations.json b/dd-openfeature/src/test/resources/malformed-allocations.json deleted file mode 100644 index 93314b69e7d..00000000000 --- a/dd-openfeature/src/test/resources/malformed-allocations.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "malformed-flag": { - "key": "malformed-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "on": { - "key": "on", - "value": "on" - } - }, - "allocations": "this-is-not-a-list" - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/dd-openfeature/src/test/resources/null-flag.json b/dd-openfeature/src/test/resources/null-flag.json deleted file mode 100644 index a4f3a6c2502..00000000000 --- a/dd-openfeature/src/test/resources/null-flag.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "null-flag": null, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/dd-openfeature/src/test/resources/null-flags.json b/dd-openfeature/src/test/resources/null-flags.json deleted file mode 100644 index a083526e553..00000000000 --- a/dd-openfeature/src/test/resources/null-flags.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": null -} diff --git a/dd-openfeature/src/test/resources/serial-id.json b/dd-openfeature/src/test/resources/serial-id.json deleted file mode 100644 index 6b7dc926854..00000000000 --- a/dd-openfeature/src/test/resources/serial-id.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": "${serialId}", - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/dd-openfeature/src/test/resources/sibling-serial-ids.json b/dd-openfeature/src/test/resources/sibling-serial-ids.json deleted file mode 100644 index aa791903f66..00000000000 --- a/dd-openfeature/src/test/resources/sibling-serial-ids.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "malformed-flag": { - "key": "malformed-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "on": { - "key": "on", - "value": "on" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": "${serialId}", - "variationKey": "on", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "serialId": 7, - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/dd-openfeature/src/test/resources/unknown-operator.json b/dd-openfeature/src/test/resources/unknown-operator.json deleted file mode 100644 index a380463b1d5..00000000000 --- a/dd-openfeature/src/test/resources/unknown-operator.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "flags": { - "operator-grease-flag": { - "key": "operator-grease-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "trap": { - "key": "trap", - "value": "trap" - } - }, - "allocations": [ - { - "key": "grease-allocation", - "rules": [ - { - "conditions": [ - { - "attribute": "country", - "operator": "not-a-real-operator", - "value": "anything" - } - ] - } - ], - "splits": [ - { - "variationKey": "trap", - "shards": [] - } - ], - "doLog": true - } - ] - }, - "valid-flag": { - "key": "valid-flag", - "enabled": true, - "variationType": "STRING", - "variations": { - "expected": { - "key": "expected", - "value": "expected" - } - }, - "allocations": [ - { - "key": "default-allocation", - "rules": [], - "splits": [ - { - "variationKey": "expected", - "shards": [] - } - ], - "doLog": true - } - ] - } - } -} diff --git a/dd-openfeature/src/test/resources/unknown-top-level-fields.json b/dd-openfeature/src/test/resources/unknown-top-level-fields.json deleted file mode 100644 index fe23d4a46b1..00000000000 --- a/dd-openfeature/src/test/resources/unknown-top-level-fields.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "createdAt": "2024-04-17T19:40:53.716Z", - "format": "SERVER", - "environment": { - "name": "Test" - }, - "segments": { - "new-schema-key": { - "ignored": true - } - }, - "flags": {} -} From c21205e28a1c7be2ca1134b9c951fcb6a5d5fa61 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 2 Oct 2026 17:49:55 +0200 Subject: [PATCH 21/50] fix(ffe): Deduplicate retry mechanism --- .../delivery/CdnConfigurationSource.java | 43 +++-------- .../delivery/DirectIntakeTransport.java | 50 +++++-------- .../internal/delivery/Retries.java | 75 +++++++++++++++++++ .../delivery/DirectIntakeTransportTest.java | 1 + 4 files changed, 108 insertions(+), 61 deletions(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java index ae14f2bb224..f2ae167b055 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -1,5 +1,7 @@ package com.datadog.openfeature.internal.delivery; +import static com.datadog.openfeature.internal.delivery.Retries.isRetryableStatus; + import com.datadog.openfeature.internal.ConfigurationService; import com.datadog.openfeature.internal.RuntimeServices; import com.datadog.openfeature.internal.config.Settings; @@ -46,7 +48,7 @@ public final class CdnConfigurationSource implements ConfigurationService { private final Duration requestTimeout; private final HttpClient httpClient; private final ScheduledExecutorService executor; - private final RetrySleeper retrySleeper; + private final Retries.Sleeper retrySleeper; private final DoubleSupplier jitter; private final Object lifecycleLock = new Object(); private final AtomicBoolean polling = new AtomicBoolean(); @@ -81,7 +83,7 @@ public CdnConfigurationSource(final Settings settings, final RuntimeServices ser final Duration requestTimeout, final HttpClient httpClient, final ScheduledExecutorService executor, - final RetrySleeper retrySleeper, + final Retries.Sleeper retrySleeper, final DoubleSupplier jitter) { this.endpoint = endpoint; this.apiKey = apiKey; @@ -230,24 +232,14 @@ private boolean apply(final HttpResponse response) { } private HttpResponse fetchWithRetries() throws IOException, InterruptedException { - final HttpRequest request = buildRequest(); - int attempt = 0; - while (true) { - attempt++; - try { - final HttpResponse response = - this.httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); - if (!isRetryableStatus(response.statusCode()) || attempt == MAX_ATTEMPTS || this.closed) { - return response; - } - } catch (final IOException e) { - if (attempt == MAX_ATTEMPTS || this.closed || Thread.currentThread().isInterrupted()) { - throw e; - } - } - this.retrySleeper.sleep( - retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble())); - } + return Retries.send( + this.httpClient, + buildRequest(), + HttpResponse.BodyHandlers.ofByteArray(), + MAX_ATTEMPTS, + attempt -> retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble()), + this.retrySleeper, + () -> this.closed); } private HttpRequest buildRequest() { @@ -276,12 +268,6 @@ private synchronized void warn(final String message, final Object... arguments) } } - static boolean isRetryableStatus(final int status) { - return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT - || status == 429 - || (status >= 500 && status <= 599); - } - static URI endpoint(final Settings settings) { final String configuredBaseUrl = settings.agentlessBaseUrl(); if (configuredBaseUrl == null) { @@ -334,9 +320,4 @@ static long retryDelayMillis( private static long clamp(final long value, final long minimum, final long maximum) { return Math.max(minimum, Math.min(maximum, value)); } - - @FunctionalInterface - interface RetrySleeper { - void sleep(long delayMillis) throws InterruptedException; - } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java index 3d63e5e295c..e20fb556a37 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java @@ -25,13 +25,13 @@ public final class DirectIntakeTransport implements EventTransport { private final String apiKey; private final String traceId; private final HttpClient httpClient; - private final CdnConfigurationSource.RetrySleeper retrySleeper; + private final Retries.Sleeper retrySleeper; DirectIntakeTransport( final URI intake, final String apiKey, final HttpClient httpClient, - final CdnConfigurationSource.RetrySleeper retrySleeper) { + final Retries.Sleeper retrySleeper) { this.intake = intake; this.apiKey = apiKey; // Intake requests are attributed to a stable, random trace id. @@ -70,34 +70,24 @@ public void post(final String route, final byte[] json) throws IOException { .header("x-datadog-parent-id", this.traceId); Headers.addMetadata(builder); final HttpRequest request = builder.POST(ofByteArray(json)).build(); - IOException failure = null; - long delayMillis = INITIAL_RETRY_DELAY_MILLIS; - for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - if (attempt > 0) { - this.retrySleeper.sleep(delayMillis); - delayMillis *= 2; - } - final int status = - this.httpClient.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); - if (status >= 200 && status < 300) { - return; - } - failure = new IOException("Request to " + route + " returned error response " + status); - if (!isRetryableStatus(status)) { - break; - } - } catch (final IOException e) { - failure = e; - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Request to " + route + " was interrupted", e); - } + final int status; + try { + status = + Retries.send( + this.httpClient, + request, + HttpResponse.BodyHandlers.discarding(), + MAX_RETRIES + 1, + attempt -> INITIAL_RETRY_DELAY_MILLIS << (attempt - 1), + this.retrySleeper, + () -> false) + .statusCode(); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request to " + route + " was interrupted", e); + } + if (status < 200 || status >= 300) { + throw new IOException("Request to " + route + " returned error response " + status); } - throw failure; - } - - static boolean isRetryableStatus(final int status) { - return status == 429 || status >= 500; } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java new file mode 100644 index 00000000000..fd6e22b7ee9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java @@ -0,0 +1,75 @@ +package com.datadog.openfeature.internal.delivery; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.function.BooleanSupplier; +import java.util.function.IntToLongFunction; + +/** Sends HTTP requests, retrying the failures that can succeed on a later attempt. */ +final class Retries { + private Retries() {} + + /** + * Sends a request until it gets a non-retryable response, the attempts are exhausted, or the + * caller gives up. + * + * @param httpClient the client to send the request with. + * @param request the request to send. + * @param bodyHandler the response body handler. + * @param maxAttempts the maximum number of attempts. + * @param retryDelayMillis the delay before the next attempt, given the failed attempt number. + * @param sleeper the sleeper waiting between attempts. + * @param cancelled whether the caller gave up on the request. + * @param the response body type. + * @return the last response, which can still have a retryable status. + * @throws IOException if the last attempt failed. + * @throws InterruptedException if interrupted while sending or waiting between attempts. + */ + static HttpResponse send( + final HttpClient httpClient, + final HttpRequest request, + final HttpResponse.BodyHandler bodyHandler, + final int maxAttempts, + final IntToLongFunction retryDelayMillis, + final Sleeper sleeper, + final BooleanSupplier cancelled) + throws IOException, InterruptedException { + int attempt = 0; + while (true) { + attempt++; + try { + final HttpResponse response = httpClient.send(request, bodyHandler); + if (!isRetryableStatus(response.statusCode()) + || attempt == maxAttempts + || cancelled.getAsBoolean()) { + return response; + } + } catch (final IOException e) { + if (attempt == maxAttempts + || cancelled.getAsBoolean() + || Thread.currentThread().isInterrupted()) { + throw e; + } + } + sleeper.sleep(retryDelayMillis.applyAsLong(attempt)); + } + } + + /** + * @param status the HTTP response status. + * @return whether a request that got this status can succeed on a later attempt. + */ + static boolean isRetryableStatus(final int status) { + return status == HttpURLConnection.HTTP_CLIENT_TIMEOUT + || status == 429 + || (status >= 500 && status <= 599); + } + + @FunctionalInterface + interface Sleeper { + void sleep(long delayMillis) throws InterruptedException; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java index 783e2ba3b88..465dbad7086 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java @@ -60,6 +60,7 @@ void postsJsonWithApiKeyAndTraceHeaders() throws Exception { @TableTest({ "scenario | status", + "client timeout | 408 ", "server error | 500 ", "unavailable | 503 ", "too many requests | 429 " From d6f70050908e2b6f225515d063426da3c47121c6 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:15:41 +0200 Subject: [PATCH 22/50] fix(ffe): Drop EvaluationMetadata in favor of OpenFeature metadata --- .../com/datadog/openfeature/DDEvaluator.java | 10 +--- .../evaluation/EvaluationMetadata.java | 50 ------------------- .../internal/evaluation/EvaluationResult.java | 10 ++-- .../internal/evaluation/FlagEvaluator.java | 9 ++-- 4 files changed, 12 insertions(+), 67 deletions(-) delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index 08af9e7e571..bf498809a27 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -182,14 +182,6 @@ public Object attribute(final String name) { }; final EvaluationResult result = core.evaluate(configuration.get(), (Class) coreType(target), key, defaultValue, adapted); - final ImmutableMetadata.ImmutableMetadataBuilder metadata = ImmutableMetadata.builder(); - for (final Map.Entry entry : result.getFlagMetadata().values().entrySet()) { - final Object value = entry.getValue(); - if (value instanceof Boolean) metadata.addBoolean(entry.getKey(), (Boolean) value); - else if (value instanceof Integer) metadata.addInteger(entry.getKey(), (Integer) value); - else if (value instanceof Long) metadata.addLong(entry.getKey(), (Long) value); - else metadata.addString(entry.getKey(), (String) value); - } final T value = result.getValue() == defaultValue ? defaultValue : mapValue(target, result.getValue()); final ProviderEvaluation evaluation = @@ -202,7 +194,7 @@ public Object attribute(final String name) { ? null : ErrorCode.valueOf(result.getErrorCode().name())) .errorMessage(result.getErrorMessage()) - .flagMetadata(metadata.build()) + .flagMetadata(result.getFlagMetadata()) .build(); if (result.isLogExposure()) { dispatchExposure(key, evaluation, result.getSerialId(), context); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java deleted file mode 100644 index b1b17d91501..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.datadog.openfeature.internal.evaluation; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -/** Immutable evaluation metadata, independent of the public OpenFeature SDK. */ -public final class EvaluationMetadata { - private final Map values; - - private EvaluationMetadata(final Map values) { - this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); - } - - public Map values() { - return values; - } - - public static EvaluationMetadataBuilder builder() { - return new EvaluationMetadataBuilder(); - } - - public static final class EvaluationMetadataBuilder { - private final Map values = new LinkedHashMap<>(); - - public EvaluationMetadataBuilder addString(String key, String value) { - values.put(key, value); - return this; - } - - public EvaluationMetadataBuilder addBoolean(String key, boolean value) { - values.put(key, value); - return this; - } - - public EvaluationMetadataBuilder addInteger(String key, int value) { - values.put(key, value); - return this; - } - - public EvaluationMetadataBuilder addLong(String key, long value) { - values.put(key, value); - return this; - } - - public EvaluationMetadata build() { - return new EvaluationMetadata(values); - } - } -} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java index e83edfd9355..d542171ecb2 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java @@ -1,5 +1,7 @@ package com.datadog.openfeature.internal.evaluation; +import dev.openfeature.sdk.ImmutableMetadata; + /** A resolved value plus product metadata. Transport and SDK adapters consume this result. */ public final class EvaluationResult { public enum ErrorCode { @@ -26,7 +28,7 @@ public enum Reason { private final String variant; private final ErrorCode errorCode; private final String errorMessage; - private final EvaluationMetadata metadata; + private final ImmutableMetadata metadata; private final boolean logExposure; private final Integer serialId; @@ -61,7 +63,7 @@ public String getErrorMessage() { return errorMessage; } - public EvaluationMetadata getFlagMetadata() { + public ImmutableMetadata getFlagMetadata() { return metadata; } @@ -86,7 +88,7 @@ public static final class Builder { private String variant; private ErrorCode errorCode; private String errorMessage; - private EvaluationMetadata metadata; + private ImmutableMetadata metadata; private boolean logExposure; private Integer serialId; @@ -115,7 +117,7 @@ public Builder errorMessage(String errorMessage) { return this; } - public Builder flagMetadata(EvaluationMetadata metadata) { + public Builder flagMetadata(ImmutableMetadata metadata) { this.metadata = metadata; return this; } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java index ac0c16675b5..ff5b9bd4558 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -16,6 +16,7 @@ import com.datadog.openfeature.internal.ufc.Split; import com.datadog.openfeature.internal.ufc.ValueType; import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.ImmutableMetadata; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -168,8 +169,8 @@ public EvaluationResult evaluate( } } - private static EvaluationMetadata consentMetadata(final boolean observeFullEvaluationData) { - return EvaluationMetadata.builder() + private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { + return ImmutableMetadata.builder() .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) .build(); } @@ -434,8 +435,8 @@ private EvaluationResult resolveVariant( // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". - final EvaluationMetadata.EvaluationMetadataBuilder metadataBuilder = - EvaluationMetadata.builder() + final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = + ImmutableMetadata.builder() .addString("flagKey", flag.key) .addString("variationType", flag.variationType.name()) .addString("allocationKey", allocation.key) From cb45873b92f63666eaaee9aea4fd7fdf8652cfe5 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:24:12 +0200 Subject: [PATCH 23/50] fix(ffe): Evaluate flags on OpenFeature types directly Exposures are reported through an ExposureListener at resolution time. --- .../com/datadog/openfeature/DDEvaluator.java | 61 +------- .../evaluation/EvaluationContext.java | 10 -- .../internal/evaluation/EvaluationResult.java | 139 ------------------ .../internal/evaluation/FlagEvaluator.java | 69 ++++++--- 4 files changed, 52 insertions(+), 227 deletions(-) delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index bf498809a27..f1ae8d1b40a 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -6,14 +6,12 @@ import com.datadog.openfeature.internal.config.Settings; import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.evaluation.ConfigurationStore; -import com.datadog.openfeature.internal.evaluation.EvaluationResult; import com.datadog.openfeature.internal.evaluation.FlagEvaluator; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.exposure.Subject; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.ValueType; -import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ImmutableStructure; @@ -106,7 +104,7 @@ class DDEvaluator implements Evaluator, Consumer { this.settings = settings; // When span enrichment is off, the __dd_* span-enrichment metadata is not attached to // evaluations, so an enabled provider pays nothing extra. - this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled()); + this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::dispatchExposure); } @Override @@ -155,55 +153,12 @@ public void accept(final ServerConfiguration config) { } @Override - @SuppressWarnings("unchecked") public ProviderEvaluation evaluate( final Class target, final String key, final T defaultValue, final EvaluationContext context) { - final com.datadog.openfeature.internal.evaluation.EvaluationContext adapted = - context == null - ? null - : new com.datadog.openfeature.internal.evaluation.EvaluationContext() { - @Override - public String getTargetingKey() { - return context.getTargetingKey(); - } - - @Override - public boolean hasAttribute(final String name) { - return context.keySet().contains(name); - } - - @Override - public Object attribute(final String name) { - return context.convertValue(context.getValue(name)); - } - }; - final EvaluationResult result = - core.evaluate(configuration.get(), (Class) coreType(target), key, defaultValue, adapted); - final T value = - result.getValue() == defaultValue ? defaultValue : mapValue(target, result.getValue()); - final ProviderEvaluation evaluation = - ProviderEvaluation.builder() - .value(value) - .reason(result.getReason()) - .variant(result.getVariant()) - .errorCode( - result.getErrorCode() == null - ? null - : ErrorCode.valueOf(result.getErrorCode().name())) - .errorMessage(result.getErrorMessage()) - .flagMetadata(result.getFlagMetadata()) - .build(); - if (result.isLogExposure()) { - dispatchExposure(key, evaluation, result.getSerialId(), context); - } - return evaluation; - } - - private static Class coreType(final Class target) { - return target == Value.class ? Object.class : target; + return core.evaluate(configuration.get(), target, key, defaultValue, context); } static boolean isAllocationActive(final Allocation allocation, final Instant now) { @@ -211,20 +166,16 @@ static boolean isAllocationActive(final Allocation allocation, final Instant now } static boolean isTypeCompatible(final Class target, final ValueType type) { - return FlagEvaluator.isTypeCompatible(coreType(target), type); + return FlagEvaluator.isTypeCompatible(target, type); } - @SuppressWarnings("unchecked") static T mapValue(final Class target, final Object value) { - if (target == Value.class) { - return value == null ? null : (T) Value.objectToValue(value); - } return FlagEvaluator.mapValue(target, value); } - private void dispatchExposure( + private void dispatchExposure( final String flag, - final ProviderEvaluation evaluation, + final ProviderEvaluation evaluation, final Integer serialId, final EvaluationContext context) { final String allocationKey = allocationKey(evaluation); @@ -255,7 +206,7 @@ void recordExposure(final ExposureEvent event) { } } - private static String allocationKey(final ProviderEvaluation resolution) { + private static String allocationKey(final ProviderEvaluation resolution) { final ImmutableMetadata meta = resolution.getFlagMetadata(); return meta == null ? null : meta.getString("allocationKey"); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java deleted file mode 100644 index 0f4112b2f3d..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.datadog.openfeature.internal.evaluation; - -/** Lazy application context; adapters retain their public SDK's value-conversion rules. */ -public interface EvaluationContext { - String getTargetingKey(); - - boolean hasAttribute(String name); - - Object attribute(String name); -} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java deleted file mode 100644 index d542171ecb2..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.datadog.openfeature.internal.evaluation; - -import dev.openfeature.sdk.ImmutableMetadata; - -/** A resolved value plus product metadata. Transport and SDK adapters consume this result. */ -public final class EvaluationResult { - public enum ErrorCode { - PROVIDER_NOT_READY, - INVALID_CONTEXT, - FLAG_NOT_FOUND, - TARGETING_KEY_MISSING, - TYPE_MISMATCH, - PARSE_ERROR, - GENERAL - } - - public enum Reason { - DISABLED, - DEFAULT, - ERROR, - TARGETING_MATCH, - SPLIT, - STATIC - } - - private final T value; - private final String reason; - private final String variant; - private final ErrorCode errorCode; - private final String errorMessage; - private final ImmutableMetadata metadata; - private final boolean logExposure; - private final Integer serialId; - - private EvaluationResult(final Builder builder) { - value = builder.value; - reason = builder.reason; - variant = builder.variant; - errorCode = builder.errorCode; - errorMessage = builder.errorMessage; - metadata = builder.metadata; - logExposure = builder.logExposure; - serialId = builder.serialId; - } - - public T getValue() { - return value; - } - - public String getReason() { - return reason; - } - - public String getVariant() { - return variant; - } - - public ErrorCode getErrorCode() { - return errorCode; - } - - public String getErrorMessage() { - return errorMessage; - } - - public ImmutableMetadata getFlagMetadata() { - return metadata; - } - - public boolean isLogExposure() { - return logExposure; - } - - /** - * @return the serial id of the resolved split, or {@code null} if absent. - */ - public Integer getSerialId() { - return serialId; - } - - public static Builder builder() { - return new Builder<>(); - } - - public static final class Builder { - private T value; - private String reason; - private String variant; - private ErrorCode errorCode; - private String errorMessage; - private ImmutableMetadata metadata; - private boolean logExposure; - private Integer serialId; - - public Builder value(T value) { - this.value = value; - return this; - } - - public Builder reason(String reason) { - this.reason = reason; - return this; - } - - public Builder variant(String variant) { - this.variant = variant; - return this; - } - - public Builder errorCode(ErrorCode errorCode) { - this.errorCode = errorCode; - return this; - } - - public Builder errorMessage(String errorMessage) { - this.errorMessage = errorMessage; - return this; - } - - public Builder flagMetadata(ImmutableMetadata metadata) { - this.metadata = metadata; - return this; - } - - public Builder serialId(Integer serialId) { - this.serialId = serialId; - return this; - } - - public Builder logExposure(boolean logExposure) { - this.logExposure = logExposure; - return this; - } - - public EvaluationResult build() { - return new EvaluationResult<>(this); - } - } -} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java index ff5b9bd4558..96fa1f90829 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -2,8 +2,6 @@ import static java.util.Arrays.asList; -import com.datadog.openfeature.internal.evaluation.EvaluationResult.ErrorCode; -import com.datadog.openfeature.internal.evaluation.EvaluationResult.Reason; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ConditionConfiguration; import com.datadog.openfeature.internal.ufc.ConditionOperator; @@ -16,7 +14,12 @@ import com.datadog.openfeature.internal.ufc.Split; import com.datadog.openfeature.internal.ufc.ValueType; import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -28,30 +31,31 @@ import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; -/** - * Local evaluation over a single configuration snapshot. No transport or OpenFeature dependency. - */ +/** Local evaluation over a single configuration snapshot. No transport dependency. */ public final class FlagEvaluator { private static final Set> SUPPORTED_RESOLUTION_TYPES = - new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Object.class)); + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); private static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; private static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; private static final String METADATA_DO_LOG = "__dd_do_log"; private final boolean spanEnrichmentEnabled; + private final ExposureListener exposureListener; - public FlagEvaluator(final boolean spanEnrichmentEnabled) { + public FlagEvaluator( + final boolean spanEnrichmentEnabled, final ExposureListener exposureListener) { this.spanEnrichmentEnabled = spanEnrichmentEnabled; + this.exposureListener = exposureListener; } - public EvaluationResult evaluate( + public ProviderEvaluation evaluate( final ServerConfiguration config, final Class target, final String key, final T defaultValue, final EvaluationContext context) { // Snapshot the config once and thread observeFullEvaluationData through every - // EvaluationResult returned, so the hook's consent decision is pinned to this evaluation's + // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's // config and cannot drift on a concurrent Remote Config swap. // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. @@ -79,7 +83,7 @@ public EvaluationResult evaluate( } if (!flag.enabled) { - return EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.DISABLED.name()) .flagMetadata(consentMetadata(observeFullEvaluationData)) @@ -154,7 +158,7 @@ public EvaluationResult evaluate( } } - return EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.DEFAULT.name()) .flagMetadata(consentMetadata(observeFullEvaluationData)) @@ -175,17 +179,17 @@ private static ImmutableMetadata consentMetadata(final boolean observeFullEvalua .build(); } - private static EvaluationResult error( + private static ProviderEvaluation error( final T defaultValue, final ErrorCode code, final String errorMessage, final boolean observeFullEvaluationData) { // Under consent-off the errorMessage is dropped: exception messages from the outer catch blocks // (NumberFormatException, generic Exception) can echo raw evaluation-context values, so they - // must never reach any consumer of EvaluationResult.getErrorMessage() — not just our own + // must never reach any consumer of ProviderEvaluation.getErrorMessage() — not just our own // wire hook. Downstream (FlagEvalLoggingHook) falls back to ErrorCode.name(), so operators // still get a stable signal like "TYPE_MISMATCH". - return EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.ERROR.name()) .errorCode(code) @@ -386,7 +390,7 @@ private static String getMD5Hash(final String input) { } } - private EvaluationResult resolveVariant( + private ProviderEvaluation resolveVariant( final Class target, final String key, final T defaultValue, @@ -453,8 +457,8 @@ private EvaluationResult resolveVariant( } metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); } - final EvaluationResult result = - EvaluationResult.builder() + final ProviderEvaluation result = + ProviderEvaluation.builder() .value(mappedValue) .reason( !isEmpty(allocation.rules) @@ -464,18 +468,19 @@ private EvaluationResult resolveVariant( : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) .variant(variant.key) .flagMetadata(metadataBuilder.build()) - .logExposure(Boolean.TRUE.equals(allocation.doLog)) - .serialId(split.serialId) .build(); + if (Boolean.TRUE.equals(allocation.doLog)) { + exposureListener.onExposure(key, result, split.serialId, context); + } return result; } private static Object resolveAttribute(final String name, final EvaluationContext context) { // Special handling for "id" attribute: if not explicitly provided, use targeting key - if ("id".equals(name) && !context.hasAttribute(name)) { + if ("id".equals(name) && !context.keySet().contains(name)) { return context.getTargetingKey(); } - return context.attribute(name); + return context.convertValue(context.getValue(name)); } public static boolean isTypeCompatible(final Class target, final ValueType variationType) { @@ -492,7 +497,7 @@ public static boolean isTypeCompatible(final Class target, final ValueType va case NUMERIC: return target == Double.class; case JSON: - return target == Object.class; + return target == Value.class; default: return true; // Unknown types pass through — mapValue errors caught as GENERAL } @@ -526,7 +531,7 @@ public static T mapValue(final Class target, final Object value) { final Double number = parseDouble(value); return (T) number; } - return (T) value; + return (T) Value.objectToValue(value); } private static Double parseDouble(final Object value) { @@ -536,6 +541,24 @@ private static Double parseDouble(final Object value) { return Double.parseDouble(String.valueOf(value)); } + /** Receives the evaluations whose allocation logs exposures. */ + @FunctionalInterface + public interface ExposureListener { + /** + * Called when an evaluation resolves to an allocation logging exposures. + * + * @param flagKey the evaluated flag key. + * @param evaluation the evaluation result. + * @param serialId the split serial id, or {@code null} if absent. + * @param context the evaluation context. + */ + void onExposure( + String flagKey, + ProviderEvaluation evaluation, + Integer serialId, + EvaluationContext context); + } + @FunctionalInterface private interface NumberComparator { boolean compare(double a, double b); From 8f13153e85e27dab9ec06ece30a089b453e1a79a Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 12:02:49 +0200 Subject: [PATCH 24/50] fix(ffe): Reject flags with an explicit null enabled field --- .../ufc/UniversalFlagConfigParser.java | 8 +++-- .../ufc/UniversalFlagConfigParserTest.java | 33 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index fb67e8c19ac..4155de6bd29 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -188,7 +188,7 @@ private static Flag toFlag(@Nullable final Object value) { final Map json = asObject(value, "flag"); return new Flag( asString(json.get("key"), "key"), - strictBoolean(json.get("enabled"), "enabled"), + strictBoolean(json, "enabled"), asEnum(json.get("variationType"), ValueType.class, "variationType"), toVariants(json.get("variations")), toList(json.get("allocations"), "allocations", UniversalFlagConfigParser::toAllocation)); @@ -333,10 +333,12 @@ private static String asString(@Nullable final Object value, final String name) throw new IllegalArgumentException(name + " is not a string"); } - private static boolean strictBoolean(@Nullable final Object value, final String name) { - if (value == null) { + /** Reads a required boolean field: absent reads {@code false}, explicit {@code null} rejects. */ + private static boolean strictBoolean(final Map json, final String name) { + if (!json.containsKey(name)) { return false; } + final Object value = json.get(name); if (value instanceof Boolean) { return (Boolean) value; } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index b4a98c417fb..00b9a757e7c 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -12,8 +12,6 @@ import java.io.InputStream; import java.time.Instant; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.tabletest.junit.TableTest; /** Parses raw UFC documents, as delivered by Remote Configuration. */ @@ -48,12 +46,19 @@ void rejectsWrongTypedFlagMap() { assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); } - @Test - void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { + @TableTest({ + "scenario | enabled ", + "string | '\"yes\"'", + "number | '1' ", + "null | 'null' " + }) + void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag(final String enabled) throws Exception { final ServerConfiguration config = deserialize( "{\"flags\":{" - + "\"bad\":{\"key\":\"bad\",\"enabled\":\"yes\"}," + + "\"bad\":{\"key\":\"bad\",\"enabled\":" + + enabled + + "}," + "\"good\":{\"key\":\"good\",\"enabled\":true}" + "}}"); @@ -63,6 +68,15 @@ void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { assertTrue(config.flags.get("good").enabled); } + @Test + void readsAbsentEnabledAsDisabled() throws Exception { + final ServerConfiguration config = deserialize("{\"flags\":{\"f\":{\"key\":\"f\"}}}"); + + assertNotNull(config); + assertFalse(config.flags.get("f").enabled); + assertTrue(config.invalidFlags.isEmpty()); + } + @Test void readsVariantNumbersAsDoubles() throws Exception { final ServerConfiguration config = @@ -131,8 +145,13 @@ void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no * validation of its own; what matters is that a bad one never rejects the sibling flag. */ - @ParameterizedTest - @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) + @TableTest({ + "scenario | wireValue | expected ", + "numeric string | '\"340132\"' | 340132 ", + "fraction truncated | '1.5' | 1 ", + "negative | '-1' | -1 ", + "overflow saturates | '2147483648' | 2147483647" + }) void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) throws Exception { final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); From 2b91f3967d1b774b7b1b27345792103f7a517006 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 12:24:55 +0200 Subject: [PATCH 25/50] fix(ffe): Reject flags with non-string allocation dates --- .../ufc/UniversalFlagConfigParser.java | 7 +++- .../ufc/UniversalFlagConfigParserTest.java | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index 4155de6bd29..3931a9505dc 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -402,12 +402,15 @@ private static long asIntegral( return (long) number; } - /** Ignores wrongly set dates. */ + /** Ignores unparsable dates and numbers, but rejects other non-string values. */ @Nullable private static Instant toInstant(@Nullable final Object value) { - if (!(value instanceof String)) { + if (value == null || value instanceof Number) { return null; } + if (!(value instanceof String)) { + throw new IllegalArgumentException("allocation date is not a string"); + } try { return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse((String) value, Instant::from); } catch (final RuntimeException e) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index 00b9a757e7c..e22ed9b6dfa 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -274,6 +274,48 @@ void testInstantParsing(final String value, final String expectedInstant) throws } } + @TableTest({ + "scenario | field | date ", + "boolean start | 'startAt' | 'true' ", + "object start | 'startAt' | '{}' ", + "array start | 'startAt' | '[]' ", + "boolean end | 'endAt' | 'false'", + "object end | 'endAt' | '{}' ", + "array end | 'endAt' | '[]' " + }) + void rejectsFlagWithNonStringAllocationDateAndKeepsSiblingFlag( + final String field, final String date) throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{" + + "\"bad\":{\"key\":\"bad\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"" + + field + + "\":" + + date + + "}]}," + + "\"good\":{\"key\":\"good\",\"enabled\":true}" + + "}}"); + + assertNotNull(config); + assertFalse(config.flags.containsKey("bad")); + assertEquals("invalid_flag", config.invalidFlags.get("bad")); + assertTrue(config.flags.containsKey("good")); + } + + @Test + void readsNumericAllocationDatesAsUnbounded() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"startAt\":1672531200,\"endAt\":1.5}]}}}"); + + final Allocation allocation = config.flags.get("f").allocations.get(0); + assertNull(allocation.startAt); + assertNull(allocation.endAt); + assertTrue(config.invalidFlags.isEmpty()); + } + private static ServerConfiguration deserialize(final String json) throws Exception { return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); } From 85bebd9dbd1589f696e9d4f3cf15d2d05d5782a6 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 15:47:29 +0200 Subject: [PATCH 26/50] fix(ffe): Reject configurations with non-finite numbers --- .../internal/ufc/UniversalFlagConfigParser.java | 6 +++++- .../internal/ufc/UniversalFlagConfigParserTest.java | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index 3931a9505dc..dda46b58394 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -108,7 +108,11 @@ private static Object readValue(final JsonParser parser) throws IOException { return parser.getText(); case VALUE_NUMBER_INT: case VALUE_NUMBER_FLOAT: - return parser.getDoubleValue(); + final double number = parser.getDoubleValue(); + if (!Double.isFinite(number)) { + throw new JsonParseException(parser, "Non-finite JSON number"); + } + return number; case VALUE_TRUE: return Boolean.TRUE; case VALUE_FALSE: diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index e22ed9b6dfa..36fd37e9e32 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -46,6 +46,16 @@ void rejectsWrongTypedFlagMap() { assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); } + @TableTest({ + "scenario | flag ", + "overflowing variant | '{\"key\":\"f\",\"variations\":{\"v\":{\"key\":\"v\",\"value\":1e400}}}' ", + "negative overflow | '{\"key\":\"f\",\"variations\":{\"v\":{\"key\":\"v\",\"value\":-1e400}}}' ", + "overflowing condition | '{\"key\":\"f\",\"allocations\":[{\"key\":\"a\",\"rules\":[{\"conditions\":[{\"value\":1e400}]}]}]}'" + }) + void rejectsNonFiniteNumbers(final String flag) { + assertThrows(IOException.class, () -> deserialize("{\"flags\":{\"f\":" + flag + "}}")); + } + @TableTest({ "scenario | enabled ", "string | '\"yes\"'", From 2b601fd56a65d3324b10d711ae1ff2436a30a153 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 06:51:50 +0200 Subject: [PATCH 27/50] fix(ffe): Detect Feature Flags activation like other products Resolve the Feature Flags product activation in Agent.start() with the other products, so it no longer needs to be volatile. --- .../src/main/java/datadog/trace/bootstrap/Agent.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 b477468fc63..25d3ddff60b 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 @@ -44,6 +44,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; @@ -134,7 +135,9 @@ private enum AgentFeature { AGENTLESS_LOG_SUBMISSION(GeneralConfig.AGENTLESS_LOG_SUBMISSION_ENABLED, false), APP_LOGS_COLLECTION(GeneralConfig.APP_LOGS_COLLECTION_ENABLED, false), LLMOBS(LlmObsConfig.LLMOBS_ENABLED, false), - LLMOBS_AGENTLESS(LlmObsConfig.LLMOBS_AGENTLESS_ENABLED, false); + LLMOBS_AGENTLESS(LlmObsConfig.LLMOBS_AGENTLESS_ENABLED, false), + FEATURE_FLAGS(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED, true), + DEPRECATED_FEATURE_FLAGS(FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, true); private final String configKey; private final String systemProp; @@ -194,7 +197,7 @@ public boolean isEnabledByDefault() { private static boolean distributedDebuggerEnabled = false; private static boolean agentlessLogSubmissionEnabled = false; private static boolean appLogsCollectionEnabled = false; - private static volatile boolean featureFlaggingEnabled = false; + private static boolean featureFlaggingEnabled = false; private static void safelySetContextClassLoader(ClassLoader classLoader) { try { @@ -283,6 +286,9 @@ public static void start( agentlessLogSubmissionEnabled = isFeatureEnabled(AgentFeature.AGENTLESS_LOG_SUBMISSION); appLogsCollectionEnabled = isFeatureEnabled(AgentFeature.APP_LOGS_COLLECTION); llmObsEnabled = isFeatureEnabled(AgentFeature.LLMOBS); + featureFlaggingEnabled = + isFeatureEnabled(AgentFeature.FEATURE_FLAGS) + && isFeatureEnabled(AgentFeature.DEPRECATED_FEATURE_FLAGS); // setup writers when llmobs is enabled to accomodate apm and llmobs if (llmObsEnabled) { @@ -1276,7 +1282,6 @@ private static void maybeStartLLMObs(Instrumentation inst, Class scoClass, Ob } private static void maybeStartFeatureFlagging(final Class scoClass, final Object sco) { - featureFlaggingEnabled = Config.get().isFeatureFlaggingProviderEnabled(); if (featureFlaggingEnabled) { StaticEventLogger.begin("Feature Flagging"); From 7af9380fa2fd080f3008d9602f89e4a944326b4b Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 07:10:11 +0200 Subject: [PATCH 28/50] refactor(ffe): Remove the feature-flagging-config module Move the FFE config keys to dd-trace-api next to the other products, and resolve the product switch and configuration source once in InstrumenterConfig, with Config delegating to it. --- dd-java-agent/agent-bootstrap/build.gradle | 1 - .../java/datadog/trace/bootstrap/Agent.java | 2 +- .../dd-openfeature-connector/build.gradle | 1 - .../ConfigurationResolutionParityTest.java | 32 +++---- .../api/config/FeatureFlaggingConfig.java | 21 +++++ gradle/dependencies.gradle | 1 - internal-api/build.gradle.kts | 1 - .../main/java/datadog/trace/api/Config.java | 37 +++----- .../datadog/trace/api/InstrumenterConfig.java | 64 +++++++++++--- .../java/datadog/trace/api/ConfigTest.java | 6 +- ...InstrumenterConfigFeatureFlaggingTest.java | 62 +++++++++++++ .../feature-flagging-agent/build.gradle.kts | 2 - .../FeatureFlaggingSystemTest.java | 4 +- .../feature-flagging-config/build.gradle.kts | 10 --- .../feature-flagging-config/gradle.lockfile | 75 ---------------- .../config/FeatureFlaggingConfig.java | 86 ------------------- .../config/FeatureFlaggingConfigTest.java | 55 ------------ .../feature-flagging-lib/build.gradle.kts | 2 - .../featureflag/FeatureFlagsBackend.java | 4 +- settings.gradle.kts | 1 - 20 files changed, 169 insertions(+), 298 deletions(-) create mode 100644 dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java create mode 100644 internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java delete mode 100644 products/feature-flagging/feature-flagging-config/build.gradle.kts delete mode 100644 products/feature-flagging/feature-flagging-config/gradle.lockfile delete mode 100644 products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java delete mode 100644 products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java diff --git a/dd-java-agent/agent-bootstrap/build.gradle b/dd-java-agent/agent-bootstrap/build.gradle index 3895cb3cbd9..76b833026f2 100644 --- a/dd-java-agent/agent-bootstrap/build.gradle +++ b/dd-java-agent/agent-bootstrap/build.gradle @@ -23,7 +23,6 @@ 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 25d3ddff60b..0ff18d9c204 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 @@ -35,6 +35,7 @@ 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,7 +45,6 @@ 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-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle index 9f1de1eb44a..5a8f8b3fd41 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle @@ -26,7 +26,6 @@ dependencies { testImplementation project(':dd-openfeature') testImplementation project(':products:feature-flagging:feature-flagging-bootstrap') - testImplementation project(':products:feature-flagging:feature-flagging-config') testImplementation libs.bundles.mockito } diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java index b5091e326fd..79d03ec4e08 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java @@ -1,9 +1,8 @@ package datadog.trace.instrumentation.openfeature; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -11,18 +10,19 @@ import com.datadog.openfeature.internal.config.Settings; import com.datadog.openfeature.internal.connector.Connector; -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; -import datadog.trace.bootstrap.config.provider.ConfigProvider; +import datadog.trace.api.InstrumenterConfig; +import datadog.trace.test.junit.utils.config.WithConfigExtension; import java.util.HashMap; import java.util.Map; -import java.util.Properties; +import org.junit.jupiter.api.extension.ExtendWith; import org.tabletest.junit.TableTest; /** - * Guards against drift between the agent ({@link FeatureFlaggingConfig}) and the SDK ({@link + * Guards against drift between the agent ({@link InstrumenterConfig}) and the SDK ({@link * Settings}) resolution of the Feature Flags product switch and configuration source. Both sides * read the same raw settings, keyed by the agent setting names. */ +@ExtendWith(WithConfigExtension.class) class ConfigurationResolutionParityTest { @TableTest({ "Scenario | Enabled | Source | Legacy | Expected ", @@ -56,17 +56,13 @@ void agentAndSdkResolveTheSameConfiguration( assertEquals(expected, sdkSource(settings), "SDK resolution"); } - /** Resolves the source like the agent: {@code InstrumenterConfig} and {@code Config}. */ + /** Resolves the source like the agent, through {@link InstrumenterConfig}. */ private static String agentSource(final Map settings) { - final Properties properties = new Properties(); - properties.putAll(settings); - final ConfigProvider configProvider = ConfigProvider.withPropertiesOverride(properties); - final FeatureFlaggingConfig.Resolution resolution = - resolveConfiguration( - configProvider.getBoolean(FEATURE_FLAGS_ENABLED), - configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE), - configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED)); - return resolution.isEnabled() ? resolution.getSource() : null; + settings.forEach(WithConfigExtension::injectSysConfig); + final InstrumenterConfig config = InstrumenterConfig.get(); + return config.isFeatureFlaggingEnabled() + ? config.getFeatureFlaggingConfigurationSource() + : null; } /** 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 new file mode 100644 index 00000000000..50296ef995b --- /dev/null +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java @@ -0,0 +1,21 @@ +package datadog.trace.api.config; + +/** Constant with names of configuration options for Feature Flags. */ +public final class FeatureFlaggingConfig { + + public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; + + /** Use {@link #FEATURE_FLAGS_ENABLED} and {@link #FEATURE_FLAGS_CONFIGURATION_SOURCE}. */ + @Deprecated + public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = + "experimental.flagging.provider.enabled"; + + public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = + "feature.flags.configuration.source"; + + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; + + public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; + + private FeatureFlaggingConfig() {} +} diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index f781cbc134a..606459f9d04 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -24,7 +24,6 @@ final class CachedData { filter.exclude(filter.project(':components:environment')) filter.exclude(filter.project(':components:json')) filter.exclude(filter.project(':products:feature-flagging:feature-flagging-bootstrap')) - filter.exclude(filter.project(':products:feature-flagging:feature-flagging-config')) filter.exclude(filter.project(':products:metrics:metrics-agent')) filter.exclude(filter.project(':products:metrics:metrics-api')) filter.exclude(filter.project(':products:metrics:metrics-lib')) diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts index a9d82cebc72..db654f09069 100644 --- a/internal-api/build.gradle.kts +++ b/internal-api/build.gradle.kts @@ -276,7 +276,6 @@ dependencies { api(project(":components:context")) api(project(":components:environment")) api(project(":components:json")) - implementation(project(":products:feature-flagging:feature-flagging-config")) api(project(":utils:config-utils")) api(project(":utils:time-utils")) diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 1bf2a2001e1..6764a106d15 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -373,6 +373,9 @@ import static datadog.trace.api.config.DebuggerConfig.THIRD_PARTY_EXCLUDES; import static datadog.trace.api.config.DebuggerConfig.THIRD_PARTY_INCLUDES; import static datadog.trace.api.config.DebuggerConfig.THIRD_PARTY_SHADING_IDENTIFIERS; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.config.GeneralConfig.AGENTLESS_LOG_SUBMISSION_LEVEL; import static datadog.trace.api.config.GeneralConfig.AGENTLESS_LOG_SUBMISSION_QUEUE_SIZE; import static datadog.trace.api.config.GeneralConfig.AGENTLESS_LOG_SUBMISSION_URL; @@ -742,11 +745,6 @@ import static datadog.trace.api.config.TracerConfig.WRITER_BAGGAGE_INJECT; import static datadog.trace.api.config.TracerConfig.WRITER_LINKS_INJECT; import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.isSupportedConfigurationSource; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY; import static datadog.trace.bootstrap.instrumentation.api.WriterConstants.MULTI_WRITER_TYPE; import static datadog.trace.bootstrap.instrumentation.api.WriterConstants.OTLP_WRITER_TYPE; @@ -764,7 +762,6 @@ import datadog.trace.api.config.OtlpConfig; import datadog.trace.api.config.ProfilingConfig; import datadog.trace.api.config.TracerConfig; -import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; import datadog.trace.api.iast.IastContext; import datadog.trace.api.iast.IastDetectionMode; import datadog.trace.api.iast.telemetry.Verbosity; @@ -1253,8 +1250,6 @@ public static String getHostName() { private final int remoteConfigMaxExtraServices; - private final String featureFlaggingConfigurationSource; - private final boolean dbmInjectSqlBaseHash; private final String dbmPropagationMode; private final boolean dbmPropagationOracleActionOnlyEnabled; @@ -2942,32 +2937,20 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) configProvider.getInteger( REMOTE_CONFIG_MAX_EXTRA_SERVICES, DEFAULT_REMOTE_CONFIG_MAX_EXTRA_SERVICES); - final Boolean configuredFeatureFlaggingProviderEnabled = - configProvider.getBoolean(FEATURE_FLAGS_ENABLED); - final Boolean legacyFeatureFlaggingProviderEnabled = - configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); - final String configuredFeatureFlaggingConfigurationSource = - configProvider.isSet(FEATURE_FLAGS_CONFIGURATION_SOURCE) - ? configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE) - : null; - final FeatureFlaggingConfig.Resolution resolvedFeatureFlaggingConfiguration = - resolveConfiguration( - configuredFeatureFlaggingProviderEnabled, - configuredFeatureFlaggingConfigurationSource, - legacyFeatureFlaggingProviderEnabled); - if (legacyFeatureFlaggingProviderEnabled != null) { + if (configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED) != null) { log.warn( "Setting {} is deprecated. Use {} and {} instead.", EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, FEATURE_FLAGS_ENABLED, FEATURE_FLAGS_CONFIGURATION_SOURCE); } - if (!isSupportedConfigurationSource(configuredFeatureFlaggingConfigurationSource)) { + final String featureFlaggingConfigurationSource = + instrumenterConfig.getFeatureFlaggingConfigurationSource(); + if (!InstrumenterConfig.isSupportedFeatureFlaggingSource(featureFlaggingConfigurationSource)) { log.warn( "Unsupported Feature Flagging configuration source; provider disabled: '{}'", - resolvedFeatureFlaggingConfiguration.getSource()); + featureFlaggingConfigurationSource); } - featureFlaggingConfigurationSource = resolvedFeatureFlaggingConfiguration.getSource(); dynamicInstrumentationEnabled = configProvider.getBoolean( @@ -4905,7 +4888,7 @@ public boolean isFeatureFlaggingProviderEnabled() { } public String getFeatureFlaggingConfigurationSource() { - return featureFlaggingConfigurationSource; + return instrumenterConfig.getFeatureFlaggingConfigurationSource(); } public boolean isDynamicInstrumentationEnabled() { @@ -6867,7 +6850,7 @@ public String toString() { + ", featureFlaggingProviderEnabled=" + isFeatureFlaggingProviderEnabled() + ", featureFlaggingConfigurationSource=" - + featureFlaggingConfigurationSource + + getFeatureFlaggingConfigurationSource() + ", debuggerEnabled=" + dynamicInstrumentationEnabled + ", debuggerUploadTimeout=" diff --git a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java index 6af92039fe3..7d86f527946 100644 --- a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java @@ -33,6 +33,11 @@ import static datadog.trace.api.config.AppSecConfig.APPSEC_ENABLED; import static datadog.trace.api.config.AppSecConfig.APPSEC_RASP_ENABLED; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.config.GeneralConfig.AGENTLESS_LOG_SUBMISSION_ENABLED; import static datadog.trace.api.config.GeneralConfig.APP_LOGS_COLLECTION_ENABLED; import static datadog.trace.api.config.GeneralConfig.DATA_JOBS_ENABLED; @@ -98,13 +103,11 @@ import static datadog.trace.api.config.TraceInstrumentationConfig.UNSAFE_CLASS_INJECTION; import static datadog.trace.api.config.TraceInstrumentationConfig.VISITOR_CLASS_PARSING; import static datadog.trace.api.config.UsmConfig.USM_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; import static datadog.trace.util.CollectionUtils.tryMakeImmutableList; import static datadog.trace.util.CollectionUtils.tryMakeImmutableSet; +import static java.lang.Boolean.FALSE; import static java.util.Collections.singletonList; +import static java.util.Locale.ROOT; import datadog.environment.JavaVirtualMachine; import datadog.trace.api.profiling.ProfilingEnablement; @@ -180,6 +183,7 @@ public class InstrumenterConfig { private final boolean telemetryEnabled; private final boolean llmObsEnabled; private final boolean featureFlaggingEnabled; + private final String featureFlaggingConfigurationSource; private final String traceExtensionsPath; @@ -281,12 +285,26 @@ private InstrumenterConfig() { CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT, DEFAULT_CODE_ORIGIN_FOR_SPANS_INTERFACE_SUPPORT); traceEnabled = configProvider.getBoolean(TRACE_ENABLED, DEFAULT_TRACE_ENABLED); - featureFlaggingEnabled = - resolveConfiguration( - configProvider.getBoolean(FEATURE_FLAGS_ENABLED), - configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE), - configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED)) - .isEnabled(); + // The product switch wins, then an explicit source, then the deprecated experimental toggle. + final Boolean featureFlagsEnabled = configProvider.getBoolean(FEATURE_FLAGS_ENABLED); + final String featureFlagsSource = + normalizeFeatureFlaggingSource( + configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE)); + final Boolean legacyFeatureFlagsEnabled = + configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); + if (FALSE.equals(featureFlagsEnabled) || featureFlagsSource != null) { + featureFlaggingEnabled = + !FALSE.equals(featureFlagsEnabled) + && isSupportedFeatureFlaggingSource(featureFlagsSource); + featureFlaggingConfigurationSource = featureFlagsSource; + } else if (legacyFeatureFlagsEnabled != null) { + featureFlaggingEnabled = legacyFeatureFlagsEnabled; + featureFlaggingConfigurationSource = + legacyFeatureFlagsEnabled ? CONFIGURATION_SOURCE_REMOTE_CONFIG : null; + } else { + featureFlaggingEnabled = true; + featureFlaggingConfigurationSource = CONFIGURATION_SOURCE_AGENTLESS; + } traceOtelEnabled = configProvider.getBoolean(TRACE_OTEL_ENABLED, DEFAULT_TRACE_OTEL_ENABLED); metricsOtelEnabled = configProvider.getBoolean(METRICS_OTEL_ENABLED, DEFAULT_METRICS_OTEL_ENABLED); @@ -506,6 +524,32 @@ public boolean isFeatureFlaggingEnabled() { return featureFlaggingEnabled; } + /** + * @return the normalized Feature Flags configuration source, unsupported values included, or + * {@code null} when none is configured. + */ + public String getFeatureFlaggingConfigurationSource() { + return featureFlaggingConfigurationSource; + } + + /** + * @param source the normalized configuration source. + * @return whether the source is absent or supported. + */ + static boolean isSupportedFeatureFlaggingSource(final String source) { + return source == null + || CONFIGURATION_SOURCE_AGENTLESS.equals(source) + || CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(source); + } + + private static String normalizeFeatureFlaggingSource(final String source) { + if (source == null) { + return null; + } + final String normalized = source.trim().toLowerCase(ROOT); + return normalized.isEmpty() ? null : normalized; + } + public boolean isTraceOtelEnabled() { return traceOtelEnabled; } diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java index 2b6819db8d1..8854cbd162f 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java @@ -165,9 +165,9 @@ import static datadog.trace.api.config.TracerConfig.TRACE_X_DATADOG_TAGS_MAX_LENGTH; import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; import static datadog.trace.api.env.FixedCapturedEnvironment.useFixedEnv; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.bootstrap.config.provider.ConfigProvider.withPropertiesOverride; import static java.util.Arrays.asList; import static java.util.Collections.emptyList; diff --git a/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java b/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java new file mode 100644 index 00000000000..ad8edf30208 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java @@ -0,0 +1,62 @@ +package datadog.trace.api; + +import static datadog.trace.api.InstrumenterConfig.isSupportedFeatureFlaggingSource; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import java.util.Properties; +import org.tabletest.junit.TableTest; + +class InstrumenterConfigFeatureFlaggingTest { + + @TableTest({ + "scenario | enabled | source | legacy | resolvedEnabled | resolvedSource ", + "defaults | | | | true | 'agentless' ", + "blank source | | ' ' | | true | 'agentless' ", + "legacy enabled | | | 'true' | true | 'remote_config'", + "legacy disabled | | | 'false' | false | ", + "source wins over legacy | | 'agentless' | 'true' | true | 'agentless' ", + "normalized source | | ' remote_CONFIG ' | 'false' | true | 'remote_config'", + "disabled wins over source | 'false' | 'agentless' | 'true' | false | 'agentless' ", + "disabled without source | 'false' | | | false | ", + "unsupported source | | 'invalid' | | false | 'invalid' ", + "unsupported normalized | | ' OFFLINE ' | 'true' | false | 'offline' " + }) + void resolvesFeatureFlagging( + final String enabled, + final String source, + final String legacy, + final boolean resolvedEnabled, + final String resolvedSource) { + final Properties properties = new Properties(); + if (enabled != null) { + properties.setProperty(FEATURE_FLAGS_ENABLED, enabled); + } + if (source != null) { + properties.setProperty(FEATURE_FLAGS_CONFIGURATION_SOURCE, source); + } + if (legacy != null) { + properties.setProperty(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, legacy); + } + + final InstrumenterConfig config = + new InstrumenterConfig(ConfigProvider.withPropertiesOverride(properties)); + + assertEquals(resolvedEnabled, config.isFeatureFlaggingEnabled()); + assertEquals(resolvedSource, config.getFeatureFlaggingConfigurationSource()); + } + + @TableTest({ + "scenario | source | supported", + "absent | | true ", + "agentless | 'agentless' | true ", + "remote config | 'remote_config' | true ", + "unsupported | 'offline' | false " + }) + void recognizesSupportedSources(final String source, final boolean supported) { + assertEquals(supported, isSupportedFeatureFlaggingSource(source)); + } +} diff --git a/products/feature-flagging/feature-flagging-agent/build.gradle.kts b/products/feature-flagging/feature-flagging-agent/build.gradle.kts index b15250c5c0b..8962b2f5a35 100644 --- a/products/feature-flagging/feature-flagging-agent/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-agent/build.gradle.kts @@ -14,11 +14,9 @@ dependencies { api(libs.slf4j) api(project(":products:feature-flagging:feature-flagging-lib")) api(project(":internal-api")) - compileOnly(project(":products:feature-flagging:feature-flagging-config")) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) - testImplementation(project(":products:feature-flagging:feature-flagging-config")) testImplementation(project(":utils:test-utils")) testRuntimeOnly(project(":dd-trace-core")) } diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java index 5e6cbfb5d48..0935d1914cb 100644 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java +++ b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java @@ -1,8 +1,8 @@ package com.datadog.featureflag; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIGURATION_ENABLED; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; diff --git a/products/feature-flagging/feature-flagging-config/build.gradle.kts b/products/feature-flagging/feature-flagging-config/build.gradle.kts deleted file mode 100644 index c7a1f989611..00000000000 --- a/products/feature-flagging/feature-flagging-config/build.gradle.kts +++ /dev/null @@ -1,10 +0,0 @@ -plugins { - `java-library` - id("dd-trace-java.module.internal-api") -} - -description = "Feature flagging configuration keys and source resolution" - -dependencies { - testImplementation(libs.bundles.junit5) -} diff --git a/products/feature-flagging/feature-flagging-config/gradle.lockfile b/products/feature-flagging/feature-flagging-config/gradle.lockfile deleted file mode 100644 index adf89247afa..00000000000 --- a/products/feature-flagging/feature-flagging-config/gradle.lockfile +++ /dev/null @@ -1,75 +0,0 @@ -# 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.10.4=compileClasspath,spotbugs -com.github.spotbugs:spotbugs:4.10.4=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.14.0=spotbugs -com.google.errorprone:error_prone_annotations:2.48.0=spotbugs -com.thoughtworks.qdox:qdox:1.12.1=codenarc -commons-io:commons-io:2.21.0=spotbugs -de.thetaphi:forbiddenapis:3.11=compileClasspath -io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath -jaxen:jaxen:2.0.6=spotbugs -net.bytebuddy:byte-buddy-agent:1.12.8=testRuntimeClasspath -net.bytebuddy:byte-buddy:1.12.8=testRuntimeClasspath -net.sf.saxon:Saxon-HE:12.10=spotbugs -org.apache.ant:ant-antlr:1.10.14=codenarc -org.apache.ant:ant-junit:1.10.14=codenarc -org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=spotbugs -org.apache.commons:commons-text:1.15.0=spotbugs -org.apache.logging.log4j:log4j-api:2.26.1=spotbugs -org.apache.logging.log4j:log4j-core:2.26.1=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -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.15=jacocoAgent,jacocoAnt -org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt -org.jacoco:org.jacoco.core:0.8.15=jacocoAnt -org.jacoco:org.jacoco.report:0.8.15=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.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.10.1=spotbugs -org.ow2.asm:asm-commons:9.10.1=jacocoAnt,spotbugs -org.ow2.asm:asm-tree:9.10.1=jacocoAnt,spotbugs -org.ow2.asm:asm-util:9.10.1=spotbugs -org.ow2.asm:asm:9.10.1=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=spotbugsSlf4j -org.slf4j:slf4j-api:2.0.18=spotbugs -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.2=testCompileClasspath,testRuntimeClasspath -org.tabletest:tabletest-parser:1.2.1=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 deleted file mode 100644 index d3afe4dbbd8..00000000000 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ /dev/null @@ -1,86 +0,0 @@ -package datadog.trace.api.featureflag.config; - -import static java.lang.Boolean.FALSE; -import static java.util.Locale.ROOT; - -/** - * The Feature Flags settings the agent needs: the product switch and the configuration source. The - * {@code dd-openfeature} SDK resolves them with the same semantics, see {@code - * ConfigurationResolutionParityTest} in the {@code dd-openfeature-connector} instrumentation. - */ -public class FeatureFlaggingConfig { - - public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; - public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; - - private static final Resolution DISABLED_RESOLUTION = new Resolution(false, null); - private static final Resolution AGENTLESS_CONFIGURATION = - new Resolution(true, CONFIGURATION_SOURCE_AGENTLESS); - private static final Resolution REMOTE_CONFIG_CONFIGURATION = - new Resolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG); - - public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; - public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = - "experimental.flagging.provider.enabled"; - - public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = - "feature.flags.configuration.source"; - - public static Resolution resolveConfiguration( - final Boolean providerEnabled, - final String explicitSource, - final Boolean legacyProviderEnabled) { - final String normalizedSource = normalizeConfigurationSource(explicitSource); - if (FALSE.equals(providerEnabled)) { - return new Resolution(false, normalizedSource); - } - if (normalizedSource != null) { - if (CONFIGURATION_SOURCE_AGENTLESS.equals(normalizedSource)) { - return AGENTLESS_CONFIGURATION; - } - if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(normalizedSource)) { - return REMOTE_CONFIG_CONFIGURATION; - } - return new Resolution(false, normalizedSource); - } - if (legacyProviderEnabled != null) { - return legacyProviderEnabled ? REMOTE_CONFIG_CONFIGURATION : DISABLED_RESOLUTION; - } - return AGENTLESS_CONFIGURATION; - } - - public static boolean isSupportedConfigurationSource(final String source) { - final String normalizedSource = normalizeConfigurationSource(source); - return normalizedSource == null - || CONFIGURATION_SOURCE_AGENTLESS.equals(normalizedSource) - || CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(normalizedSource); - } - - private static String normalizeConfigurationSource(final String source) { - if (source == null) { - return null; - } - final String normalized = source.trim().toLowerCase(ROOT); - return normalized.isEmpty() ? null : normalized; - } - - public static final class Resolution { - private final boolean enabled; - private final String source; - - private Resolution(final boolean enabled, final String source) { - this.enabled = enabled; - this.source = source; - } - - public boolean isEnabled() { - return enabled; - } - - public String getSource() { - return source; - } - } - - private FeatureFlaggingConfig() {} -} diff --git a/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java b/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java deleted file mode 100644 index 16283869d71..00000000000 --- a/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package datadog.trace.api.featureflag.config; - -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.isSupportedConfigurationSource; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfiguration; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class FeatureFlaggingConfigTest { - - @Test - void appliesConfigurationPrecedence() { - assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, null, null); - assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, " ", null); - assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, null, true); - assertResolution(false, null, null, null, false); - assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, "agentless", true); - assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, " remote_CONFIG ", false); - assertResolution(false, CONFIGURATION_SOURCE_AGENTLESS, false, "agentless", true); - assertResolution(false, "invalid", null, "invalid", null); - assertResolution(false, "offline", null, " OFFLINE ", true); - } - - @Test - void recognizesSupportedExplicitSources() { - assertTrue(isSupportedConfigurationSource(null)); - assertTrue(isSupportedConfigurationSource(" ")); - assertTrue(isSupportedConfigurationSource("agentless")); - assertTrue(isSupportedConfigurationSource(" REMOTE_CONFIG ")); - assertFalse(isSupportedConfigurationSource("invalid")); - assertFalse(isSupportedConfigurationSource("offline")); - } - - private static void assertResolution( - final boolean enabled, - final String source, - final Boolean providerEnabled, - final String explicitSource, - final Boolean legacyProviderEnabled) { - final FeatureFlaggingConfig.Resolution resolution = - resolveConfiguration(providerEnabled, explicitSource, legacyProviderEnabled); - - assertEquals(enabled, resolution.isEnabled()); - if (source == null) { - assertNull(resolution.getSource()); - } else { - assertEquals(source, resolution.getSource()); - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 5539b495e70..4a355fa19be 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -11,14 +11,12 @@ dependencies { api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) - compileOnly(project(":products:feature-flagging:feature-flagging-config")) // Platform JSON writer for the ffe_* tag values. compileOnly(project(":components:json")) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) - testImplementation(project(":products:feature-flagging:feature-flagging-config")) 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/FeatureFlagsBackend.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java index fdb03ee214e..9aa00200620 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java @@ -1,7 +1,7 @@ package com.datadog.featureflag; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; import datadog.communication.BackendApiFactory; import datadog.communication.ddagent.SharedCommunicationObjects; diff --git a/settings.gradle.kts b/settings.gradle.kts index a4e2b8ab815..8ac120a8520 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -165,7 +165,6 @@ include(":dd-java-agent:agent-aiguard") include( ":products:feature-flagging:feature-flagging-agent", ":products:feature-flagging:feature-flagging-bootstrap", - ":products:feature-flagging:feature-flagging-config", ":products:feature-flagging:feature-flagging-lib" ) From 31d084017bbe607922f8f7a77f2b523bcf9ae1a0 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 07:49:01 +0200 Subject: [PATCH 29/50] refactor(ffe): Parse the Feature Flags configuration source as an enum --- .../ConfigurationResolutionParityTest.java | 42 ++++++++------- .../openfeature/internal/config/Settings.java | 11 +++- .../internal/config/SettingsTest.java | 29 +++++----- .../api/config/FeatureFlaggingConfig.java | 4 -- .../main/java/datadog/trace/api/Config.java | 9 ++-- .../trace/api/FeatureFlaggingSource.java | 13 +++++ .../datadog/trace/api/InstrumenterConfig.java | 54 ++++++++----------- .../java/datadog/trace/api/ConfigTest.java | 39 +++++++------- ...InstrumenterConfigFeatureFlaggingTest.java | 37 +++++-------- .../featureflag/FeatureFlagsBackend.java | 9 ++-- .../featureflag/FeatureFlagsBackendTest.java | 18 ++++--- 11 files changed, 133 insertions(+), 132 deletions(-) create mode 100644 internal-api/src/main/java/datadog/trace/api/FeatureFlaggingSource.java diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java index 79d03ec4e08..1fbd430dfcd 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java @@ -3,6 +3,7 @@ import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; +import static java.util.Locale.ROOT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -25,25 +26,26 @@ @ExtendWith(WithConfigExtension.class) class ConfigurationResolutionParityTest { @TableTest({ - "Scenario | Enabled | Source | Legacy | Expected ", - "defaults | | | | agentless ", - "enabled | true | | | agentless ", - "enabled as number | 1 | | | agentless ", - "disabled | false | | | ", - "disabled as number | 0 | | | ", - "disabled wins over source | false | remote_config | | ", - "remote config source | | remote_config | | remote_config", - "agentless source | | agentless | | agentless ", - "source is normalized | | ' Remote_Config' | | remote_config", - "unsupported source | | offline | | ", - "legacy enabled | | | true | remote_config", - "legacy disabled | | | false | ", - "source wins over legacy | | agentless | true | agentless ", - "source wins over legacy disabled | | remote_config | false | remote_config", - "enabled with legacy disabled | true | | false | ", - "uppercase boolean | TRUE | | TRUE | remote_config", - "invalid boolean reads as false | maybe | | | ", - "invalid legacy reads as false | | | maybe | " + "Scenario | Enabled | Source | Legacy | Expected ", + "defaults | | | | agentless ", + "enabled | true | | | agentless ", + "enabled as number | 1 | | | agentless ", + "disabled | false | | | ", + "disabled as number | 0 | | | ", + "disabled wins over source | false | remote_config | | ", + "remote config source | | remote_config | | remote_config", + "agentless source | | agentless | | agentless ", + "source is case insensitive | | Remote_Config | | remote_config", + "padded source is unsupported | | ' agentless ' | | ", + "unsupported source | | offline | | ", + "legacy enabled | | | true | remote_config", + "legacy disabled | | | false | ", + "source wins over legacy | | agentless | true | agentless ", + "source wins over legacy disabled | | remote_config | false | remote_config", + "enabled with legacy disabled | true | | false | ", + "uppercase boolean | TRUE | | TRUE | remote_config", + "invalid boolean reads as false | maybe | | | ", + "invalid legacy reads as false | | | maybe | " }) void agentAndSdkResolveTheSameConfiguration( final String enabled, final String source, final String legacy, final String expected) { @@ -61,7 +63,7 @@ private static String agentSource(final Map settings) { settings.forEach(WithConfigExtension::injectSysConfig); final InstrumenterConfig config = InstrumenterConfig.get(); return config.isFeatureFlaggingEnabled() - ? config.getFeatureFlaggingConfigurationSource() + ? config.getFeatureFlaggingConfigurationSource().name().toLowerCase(ROOT) : null; } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index a175b5d43bf..0006e95d548 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -90,7 +90,7 @@ public String configurationSource() { @Nullable private String resolveSource() { final Boolean enabled = getBoolean(FEATURE_FLAGS_ENABLED); - final String source = normalize(get(FEATURE_FLAGS_CONFIGURATION_SOURCE)); + final String source = normalizeSource(get(FEATURE_FLAGS_CONFIGURATION_SOURCE)); if (Boolean.FALSE.equals(enabled)) { return null; } @@ -238,6 +238,15 @@ private static String normalize(@Nullable final String value) { return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT); } + /** + * Normalizes the configuration source like the agent enum parsing: case insensitive, with no + * trimming of surrounding spaces. + */ + @Nullable + private static String normalizeSource(@Nullable final String value) { + return nonBlank(value) == null ? null : value.replace('/', '_').toLowerCase(Locale.ROOT); + } + @Nullable private static String nonBlank(@Nullable final String value) { if (value == null) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java index c2774b4c9a9..cf1f09f18dd 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -44,20 +44,21 @@ void mapsKeysToEnvironmentVariables(final String key, final String variable) { } @TableTest({ - "scenario | enabled | source | legacy | resolved ", - "default | | | | 'agentless' ", - "explicit agentless | | 'agentless' | | 'agentless' ", - "explicit remote config | | 'remote_config' | | 'remote_config'", - "normalized source | | ' Remote_Config ' | | 'remote_config'", - "unsupported source | | 'offline' | | ", - "blank source | | ' ' | | 'agentless' ", - "disabled wins over source | 'false' | 'remote_config' | | ", - "enabled keeps default | 'true' | | | 'agentless' ", - "legacy enabled selects rc | | | 'true' | 'remote_config'", - "legacy disabled disables | | | 'false' | ", - "explicit source wins over legacy | | 'agentless' | 'false' | 'agentless' ", - "invalid enabled reads as false | 'maybe' | | | ", - "numeric disabled | '0' | | | " + "scenario | enabled | source | legacy | resolved ", + "default | | | | 'agentless' ", + "explicit agentless | | 'agentless' | | 'agentless' ", + "explicit remote config | | 'remote_config' | | 'remote_config'", + "case insensitive source | | 'Remote_Config' | | 'remote_config'", + "padded source | | ' agentless ' | | ", + "unsupported source | | 'offline' | | ", + "blank source | | ' ' | | 'agentless' ", + "disabled wins over source | 'false' | 'remote_config' | | ", + "enabled keeps default | 'true' | | | 'agentless' ", + "legacy enabled selects rc | | | 'true' | 'remote_config'", + "legacy disabled disables | | | 'false' | ", + "explicit source wins over legacy | | 'agentless' | 'false' | 'agentless' ", + "invalid enabled reads as false | 'maybe' | | | ", + "numeric disabled | '0' | | | " }) void resolvesConfigurationSource( final String enabled, final String source, final String legacy, final String resolved) { 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 index 50296ef995b..9a232111344 100644 --- 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 @@ -13,9 +13,5 @@ public final class FeatureFlaggingConfig { public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; - public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; - - public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; - private FeatureFlaggingConfig() {} } diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 6764a106d15..5195c519238 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -2944,12 +2944,11 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) FEATURE_FLAGS_ENABLED, FEATURE_FLAGS_CONFIGURATION_SOURCE); } - final String featureFlaggingConfigurationSource = - instrumenterConfig.getFeatureFlaggingConfigurationSource(); - if (!InstrumenterConfig.isSupportedFeatureFlaggingSource(featureFlaggingConfigurationSource)) { + if (instrumenterConfig.getFeatureFlaggingConfigurationSource() + == FeatureFlaggingSource.INVALID) { log.warn( "Unsupported Feature Flagging configuration source; provider disabled: '{}'", - featureFlaggingConfigurationSource); + configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE)); } dynamicInstrumentationEnabled = @@ -4887,7 +4886,7 @@ public boolean isFeatureFlaggingProviderEnabled() { return instrumenterConfig.isFeatureFlaggingEnabled(); } - public String getFeatureFlaggingConfigurationSource() { + public FeatureFlaggingSource getFeatureFlaggingConfigurationSource() { return instrumenterConfig.getFeatureFlaggingConfigurationSource(); } diff --git a/internal-api/src/main/java/datadog/trace/api/FeatureFlaggingSource.java b/internal-api/src/main/java/datadog/trace/api/FeatureFlaggingSource.java new file mode 100644 index 00000000000..bf8c9921822 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/FeatureFlaggingSource.java @@ -0,0 +1,13 @@ +package datadog.trace.api; + +/** The Feature Flags configuration source. */ +public enum FeatureFlaggingSource { + /** No source is configured. */ + UNSET, + /** The configured source is not supported. */ + INVALID, + /** The flag configuration is polled from the Datadog CDN. */ + AGENTLESS, + /** The flag configuration is received through Remote Configuration. */ + REMOTE_CONFIG +} diff --git a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java index 7d86f527946..64a3e23db76 100644 --- a/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/InstrumenterConfig.java @@ -29,12 +29,14 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_OTEL_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_USM_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_WEBSOCKET_MESSAGES_ENABLED; +import static datadog.trace.api.FeatureFlaggingSource.AGENTLESS; +import static datadog.trace.api.FeatureFlaggingSource.INVALID; +import static datadog.trace.api.FeatureFlaggingSource.REMOTE_CONFIG; +import static datadog.trace.api.FeatureFlaggingSource.UNSET; import static datadog.trace.api.config.AppSecConfig.API_SECURITY_ENDPOINT_COLLECTION_ENABLED; import static datadog.trace.api.config.AppSecConfig.APPSEC_ENABLED; import static datadog.trace.api.config.AppSecConfig.APPSEC_RASP_ENABLED; import static datadog.trace.api.config.CiVisibilityConfig.CIVISIBILITY_ENABLED; -import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; @@ -107,7 +109,6 @@ import static datadog.trace.util.CollectionUtils.tryMakeImmutableSet; import static java.lang.Boolean.FALSE; import static java.util.Collections.singletonList; -import static java.util.Locale.ROOT; import datadog.environment.JavaVirtualMachine; import datadog.trace.api.profiling.ProfilingEnablement; @@ -183,7 +184,7 @@ public class InstrumenterConfig { private final boolean telemetryEnabled; private final boolean llmObsEnabled; private final boolean featureFlaggingEnabled; - private final String featureFlaggingConfigurationSource; + private final FeatureFlaggingSource featureFlaggingConfigurationSource; private final String traceExtensionsPath; @@ -287,23 +288,18 @@ private InstrumenterConfig() { traceEnabled = configProvider.getBoolean(TRACE_ENABLED, DEFAULT_TRACE_ENABLED); // The product switch wins, then an explicit source, then the deprecated experimental toggle. final Boolean featureFlagsEnabled = configProvider.getBoolean(FEATURE_FLAGS_ENABLED); - final String featureFlagsSource = - normalizeFeatureFlaggingSource( - configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE)); + final FeatureFlaggingSource featureFlagsSource = featureFlaggingSource(configProvider); final Boolean legacyFeatureFlagsEnabled = configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); - if (FALSE.equals(featureFlagsEnabled) || featureFlagsSource != null) { - featureFlaggingEnabled = - !FALSE.equals(featureFlagsEnabled) - && isSupportedFeatureFlaggingSource(featureFlagsSource); + if (FALSE.equals(featureFlagsEnabled) || featureFlagsSource != UNSET) { + featureFlaggingEnabled = !FALSE.equals(featureFlagsEnabled) && featureFlagsSource != INVALID; featureFlaggingConfigurationSource = featureFlagsSource; } else if (legacyFeatureFlagsEnabled != null) { featureFlaggingEnabled = legacyFeatureFlagsEnabled; - featureFlaggingConfigurationSource = - legacyFeatureFlagsEnabled ? CONFIGURATION_SOURCE_REMOTE_CONFIG : null; + featureFlaggingConfigurationSource = legacyFeatureFlagsEnabled ? REMOTE_CONFIG : UNSET; } else { featureFlaggingEnabled = true; - featureFlaggingConfigurationSource = CONFIGURATION_SOURCE_AGENTLESS; + featureFlaggingConfigurationSource = AGENTLESS; } traceOtelEnabled = configProvider.getBoolean(TRACE_OTEL_ENABLED, DEFAULT_TRACE_OTEL_ENABLED); metricsOtelEnabled = @@ -525,29 +521,23 @@ public boolean isFeatureFlaggingEnabled() { } /** - * @return the normalized Feature Flags configuration source, unsupported values included, or - * {@code null} when none is configured. + * @return the Feature Flags configuration source, {@link FeatureFlaggingSource#UNSET} when none + * is configured or {@link FeatureFlaggingSource#INVALID} when unsupported. */ - public String getFeatureFlaggingConfigurationSource() { + public FeatureFlaggingSource getFeatureFlaggingConfigurationSource() { return featureFlaggingConfigurationSource; } - /** - * @param source the normalized configuration source. - * @return whether the source is absent or supported. - */ - static boolean isSupportedFeatureFlaggingSource(final String source) { - return source == null - || CONFIGURATION_SOURCE_AGENTLESS.equals(source) - || CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(source); - } - - private static String normalizeFeatureFlaggingSource(final String source) { - if (source == null) { - return null; + private static FeatureFlaggingSource featureFlaggingSource(final ConfigProvider configProvider) { + final String value = configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE); + if (value == null || value.trim().isEmpty()) { + return UNSET; } - final String normalized = source.trim().toLowerCase(ROOT); - return normalized.isEmpty() ? null : normalized; + final FeatureFlaggingSource source = + configProvider.getEnum( + FEATURE_FLAGS_CONFIGURATION_SOURCE, FeatureFlaggingSource.class, INVALID); + // UNSET is not a valid value to configure. + return source == UNSET ? INVALID : source; } public boolean isTraceOtelEnabled() { diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java index 8854cbd162f..dad933e7047 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java @@ -3649,15 +3649,16 @@ void traceResourceRenamingAlwaysSimplifiedEndpointWithValue(String value, boolea } @TableTest({ - "scenario | value | expected ", - "not set | | agentless ", - "empty | '' | agentless ", - "blank | ' ' | agentless ", - "mixed case padded | ' ReMoTe_ConFiG ' | remote_config ", - "unknown source | not-a-real-source | not-a-real-source", - "padded uppercase | ' OFFLINE ' | offline " + "scenario | value | expected ", + "not set | | AGENTLESS ", + "empty | '' | AGENTLESS ", + "blank | ' ' | AGENTLESS ", + "case insensitive | ReMoTe_ConFiG | REMOTE_CONFIG", + "padded | ' remote_config ' | INVALID ", + "unknown source | not-a-real-source | INVALID ", + "unset is not a value | unset | INVALID " }) - void featureFlagConfigurationSourceNormalizesValue(String value, String expected) + void featureFlagConfigurationSourceParsesValue(String value, FeatureFlaggingSource expected) throws Exception { Properties properties = new Properties(); if (value != null) { @@ -3671,23 +3672,23 @@ void featureFlagConfigurationSourceNormalizesValue(String value, String expected @TableTest({ "scenario | providerEnabled | source | legacyProviderEnabled | expectedEnabled | expectedSource", - "nothing set | | | | true | agentless ", - "provider enabled | true | | | true | agentless ", - "legacy enabled | | | true | true | remote_config ", - "legacy disabled | | | false | false | ", - "source wins over legacy enabled | | agentless | true | true | agentless ", - "source wins over legacy disabled | | remote_config | false | true | remote_config ", - "provider disabled wins over legacy | false | agentless | true | false | agentless ", - "legacy disabled wins over provider | true | | false | false | ", - "unknown source | | not-a-source | | false | not-a-source ", - "offline source | | offline | true | false | offline " + "nothing set | | | | true | AGENTLESS ", + "provider enabled | true | | | true | AGENTLESS ", + "legacy enabled | | | true | true | REMOTE_CONFIG ", + "legacy disabled | | | false | false | UNSET ", + "source wins over legacy enabled | | agentless | true | true | AGENTLESS ", + "source wins over legacy disabled | | remote_config | false | true | REMOTE_CONFIG ", + "provider disabled wins over legacy | false | agentless | true | false | AGENTLESS ", + "legacy disabled wins over provider | true | | false | false | UNSET ", + "unknown source | | not-a-source | | false | INVALID ", + "offline source | | offline | true | false | INVALID " }) void featureFlagConfigurationAppliesMigrationPrecedence( Boolean providerEnabled, String source, Boolean legacyProviderEnabled, boolean expectedEnabled, - String expectedSource) + FeatureFlaggingSource expectedSource) throws Exception { Properties properties = new Properties(); if (providerEnabled != null) { diff --git a/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java b/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java index ad8edf30208..5e5af37b723 100644 --- a/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java +++ b/internal-api/src/test/java/datadog/trace/api/InstrumenterConfigFeatureFlaggingTest.java @@ -1,6 +1,5 @@ package datadog.trace.api; -import static datadog.trace.api.InstrumenterConfig.isSupportedFeatureFlaggingSource; import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; @@ -13,24 +12,25 @@ class InstrumenterConfigFeatureFlaggingTest { @TableTest({ - "scenario | enabled | source | legacy | resolvedEnabled | resolvedSource ", - "defaults | | | | true | 'agentless' ", - "blank source | | ' ' | | true | 'agentless' ", - "legacy enabled | | | 'true' | true | 'remote_config'", - "legacy disabled | | | 'false' | false | ", - "source wins over legacy | | 'agentless' | 'true' | true | 'agentless' ", - "normalized source | | ' remote_CONFIG ' | 'false' | true | 'remote_config'", - "disabled wins over source | 'false' | 'agentless' | 'true' | false | 'agentless' ", - "disabled without source | 'false' | | | false | ", - "unsupported source | | 'invalid' | | false | 'invalid' ", - "unsupported normalized | | ' OFFLINE ' | 'true' | false | 'offline' " + "scenario | enabled | source | legacy | resolvedEnabled | resolvedSource", + "defaults | | | | true | AGENTLESS ", + "blank source | | ' ' | | true | AGENTLESS ", + "legacy enabled | | | 'true' | true | REMOTE_CONFIG ", + "legacy disabled | | | 'false' | false | UNSET ", + "source wins over legacy | | 'agentless' | 'true' | true | AGENTLESS ", + "case insensitive source | | 'Remote_Config' | 'false' | true | REMOTE_CONFIG ", + "disabled wins over source | 'false' | 'agentless' | 'true' | false | AGENTLESS ", + "disabled without source | 'false' | | | false | UNSET ", + "unsupported source | | 'offline' | 'true' | false | INVALID ", + "padded source | | ' agentless ' | | false | INVALID ", + "unset is not a value | | 'unset' | | false | INVALID " }) void resolvesFeatureFlagging( final String enabled, final String source, final String legacy, final boolean resolvedEnabled, - final String resolvedSource) { + final FeatureFlaggingSource resolvedSource) { final Properties properties = new Properties(); if (enabled != null) { properties.setProperty(FEATURE_FLAGS_ENABLED, enabled); @@ -48,15 +48,4 @@ void resolvesFeatureFlagging( assertEquals(resolvedEnabled, config.isFeatureFlaggingEnabled()); assertEquals(resolvedSource, config.getFeatureFlaggingConfigurationSource()); } - - @TableTest({ - "scenario | source | supported", - "absent | | true ", - "agentless | 'agentless' | true ", - "remote config | 'remote_config' | true ", - "unsupported | 'offline' | false " - }) - void recognizesSupportedSources(final String source, final boolean supported) { - assertEquals(supported, isSupportedFeatureFlaggingSource(source)); - } } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java index 9aa00200620..c8fc97bf8b3 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/FeatureFlagsBackend.java @@ -1,7 +1,7 @@ package com.datadog.featureflag; -import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import static datadog.trace.api.FeatureFlaggingSource.AGENTLESS; +import static datadog.trace.api.FeatureFlaggingSource.REMOTE_CONFIG; import datadog.communication.BackendApiFactory; import datadog.communication.ddagent.SharedCommunicationObjects; @@ -34,7 +34,7 @@ public FeatureFlagsBackend(final SharedCommunicationObjects sco, final Config co config, new EventProxy( new BackendApiFactory(config, sco), - CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource()) + config.getFeatureFlaggingConfigurationSource() == AGENTLESS && config.getApiKey() != null), SpanEnrichmentWriter.getInstance(), CoreMetricCollector.getInstance()); @@ -55,8 +55,7 @@ public FeatureFlagsBackend(final SharedCommunicationObjects sco, final Config co /** Registers the Remote Configuration product early when it is the configuration source. */ public void start() { - if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals( - this.config.getFeatureFlaggingConfigurationSource()) + if (this.config.getFeatureFlaggingConfigurationSource() == REMOTE_CONFIG && isRemoteConfigAvailable()) { remoteConfig().start(); } diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java index c7a101ef2ff..50331965f96 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FeatureFlagsBackendTest.java @@ -17,6 +17,7 @@ import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.Product; import datadog.trace.api.Config; +import datadog.trace.api.FeatureFlaggingSource; import datadog.trace.api.telemetry.CoreMetricCollector; import datadog.trace.bootstrap.config.provider.ConfigProvider; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; @@ -65,12 +66,12 @@ void resolvesSettingsWithAgentDefaultsFromTheAgentConfiguration() { @TableTest({ "Scenario | Source | RC enabled | Registers", - "remote config source | remote_config | true | true ", - "remote config disabled | remote_config | false | false ", - "agentless source | agentless | true | false " + "remote config source | REMOTE_CONFIG | true | true ", + "remote config disabled | REMOTE_CONFIG | false | false ", + "agentless source | AGENTLESS | true | false " }) void startRegistersRemoteConfigOnlyWhenItIsTheSource( - final String source, final boolean rcEnabled, final boolean registers) { + final FeatureFlaggingSource source, final boolean rcEnabled, final boolean registers) { final ConfigurationPoller poller = mock(ConfigurationPoller.class); when(this.sco.configurationPoller(this.config)).thenReturn(poller); when(this.config.getFeatureFlaggingConfigurationSource()).thenReturn(source); @@ -83,11 +84,12 @@ void startRegistersRemoteConfigOnlyWhenItIsTheSource( @TableTest({ "Scenario | Source | API key", - "agentless with API key | agentless | key ", - "agentless without API key | agentless | ", - "remote config | remote_config | key " + "agentless with API key | AGENTLESS | key ", + "agentless without API key | AGENTLESS | ", + "remote config | REMOTE_CONFIG | key " }) - void createsTheBackendFromTheAgentConfiguration(final String source, final String apiKey) { + void createsTheBackendFromTheAgentConfiguration( + final FeatureFlaggingSource source, final String apiKey) { when(this.config.getFeatureFlaggingConfigurationSource()).thenReturn(source); when(this.config.getApiKey()).thenReturn(apiKey); From c2682efd53697e3e37eeec6f024112b733a6d8c5 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 08:10:17 +0200 Subject: [PATCH 30/50] fix(ffe): Fix FFE activation on the product switch only --- .../src/main/java/datadog/trace/bootstrap/Agent.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 0ff18d9c204..8ca910dde7c 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 @@ -136,8 +136,7 @@ private enum AgentFeature { APP_LOGS_COLLECTION(GeneralConfig.APP_LOGS_COLLECTION_ENABLED, false), LLMOBS(LlmObsConfig.LLMOBS_ENABLED, false), LLMOBS_AGENTLESS(LlmObsConfig.LLMOBS_AGENTLESS_ENABLED, false), - FEATURE_FLAGS(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED, true), - DEPRECATED_FEATURE_FLAGS(FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, true); + FEATURE_FLAGS(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED, true); private final String configKey; private final String systemProp; @@ -286,9 +285,7 @@ public static void start( agentlessLogSubmissionEnabled = isFeatureEnabled(AgentFeature.AGENTLESS_LOG_SUBMISSION); appLogsCollectionEnabled = isFeatureEnabled(AgentFeature.APP_LOGS_COLLECTION); llmObsEnabled = isFeatureEnabled(AgentFeature.LLMOBS); - featureFlaggingEnabled = - isFeatureEnabled(AgentFeature.FEATURE_FLAGS) - && isFeatureEnabled(AgentFeature.DEPRECATED_FEATURE_FLAGS); + featureFlaggingEnabled = isFeatureEnabled(AgentFeature.FEATURE_FLAGS); // setup writers when llmobs is enabled to accomodate apm and llmobs if (llmObsEnabled) { From 03695e802479e159eaca404705c77359e0efb741 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 08:50:56 +0200 Subject: [PATCH 31/50] chore(ffe): Sort ConfigTest feature flags imports --- .../java/datadog/trace/api/ConfigTest.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java index dad933e7047..53fdf018a51 100644 --- a/internal-api/src/test/java/datadog/trace/api/ConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/ConfigTest.java @@ -42,6 +42,9 @@ import static datadog.trace.api.config.DebuggerConfig.DYNAMIC_INSTRUMENTATION_UPLOAD_TIMEOUT; import static datadog.trace.api.config.DebuggerConfig.DYNAMIC_INSTRUMENTATION_VERIFY_BYTECODE; import static datadog.trace.api.config.DebuggerConfig.EXCEPTION_REPLAY_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.api.config.GeneralConfig.API_KEY; import static datadog.trace.api.config.GeneralConfig.API_KEY_FILE; import static datadog.trace.api.config.GeneralConfig.CONFIGURATION_FILE; @@ -165,9 +168,6 @@ import static datadog.trace.api.config.TracerConfig.TRACE_X_DATADOG_TAGS_MAX_LENGTH; import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; import static datadog.trace.api.env.FixedCapturedEnvironment.useFixedEnv; -import static datadog.trace.api.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; -import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; -import static datadog.trace.api.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; import static datadog.trace.bootstrap.config.provider.ConfigProvider.withPropertiesOverride; import static java.util.Arrays.asList; import static java.util.Collections.emptyList; @@ -3649,14 +3649,14 @@ void traceResourceRenamingAlwaysSimplifiedEndpointWithValue(String value, boolea } @TableTest({ - "scenario | value | expected ", - "not set | | AGENTLESS ", - "empty | '' | AGENTLESS ", - "blank | ' ' | AGENTLESS ", - "case insensitive | ReMoTe_ConFiG | REMOTE_CONFIG", - "padded | ' remote_config ' | INVALID ", - "unknown source | not-a-real-source | INVALID ", - "unset is not a value | unset | INVALID " + "scenario | value | expected ", + "not set | | AGENTLESS ", + "empty | '' | AGENTLESS ", + "blank | ' ' | AGENTLESS ", + "case insensitive | ReMoTe_ConFiG | REMOTE_CONFIG", + "padded | ' remote_config ' | INVALID ", + "unknown source | not-a-real-source | INVALID ", + "unset is not a value | unset | INVALID " }) void featureFlagConfigurationSourceParsesValue(String value, FeatureFlaggingSource expected) throws Exception { From 530ac88c7fc9eac5736700a50b6987a1149fbd8f Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 08:50:57 +0200 Subject: [PATCH 32/50] feat(ffe): Parse features on UFC splits Port the UFC split features parsing from #12772 to the dd-openfeature SDK parser. Features only feed exposure hooks, so a malformed list or entry is dropped instead of rejecting the flag, and a feature without destinations is dropped. --- .../openfeature/internal/ufc/Feature.java | 23 ++++ .../openfeature/internal/ufc/Split.java | 4 +- .../ufc/UniversalFlagConfigParser.java | 59 +++++++++- .../ufc/UniversalFlagConfigParserTest.java | 106 ++++++++++++++++++ .../remote-config/split-features.json | 35 ++++++ 5 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java create mode 100644 dd-openfeature/src/test/resources/remote-config/split-features.json diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java new file mode 100644 index 00000000000..768f01b72e5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java @@ -0,0 +1,23 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; + +/** Key-value data that the UFC attaches to a split, with where SDKs deliver it. */ +public final class Feature { + public final String key; + + /** A {@link String}, {@link Double} or {@link Boolean}; the parser drops any other value. */ + public final Object value; + + /** + * Open strings such as {@code HOOK}, {@code EXPOSURE} or {@code EVALUATION}. Never empty: the + * parser drops a feature without destinations. + */ + public final List destinations; + + public Feature(final String key, final Object value, final List destinations) { + this.key = key; + this.value = value; + this.destinations = destinations; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java index 86d39ee9720..0897b77a058 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java @@ -10,8 +10,8 @@ public final class Split { /** Absent in some UFC shapes. Surfaced as {@code __dd_split_serial_id} for span enrichment. */ public final Integer serialId; - // Null when the UFC split has no features. A provider must check that this field exists before - // reading it, because an older agent ships a Split without it. + + /** Null when the UFC split has no features. */ public final List features; public Split( diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index dda46b58394..3a699f51553 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -1,5 +1,8 @@ package com.datadog.openfeature.internal.ufc; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; + import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParser; @@ -267,7 +270,61 @@ private static Split toSplit(@Nullable final Object value) { toList(json.get("shards"), "shards", UniversalFlagConfigParser::toShard), asString(json.get("variationKey"), "variationKey"), toStringMap(json.get("extraLogging")), - serialId == null ? null : asLenientInt(serialId, "serialId")); + serialId == null ? null : asLenientInt(serialId, "serialId"), + toFeatures(json.get("features"))); + } + + /** + * Reads a split's features. Features only feed exposure hooks, so a malformed list or entry is + * dropped instead of rejecting the flag: it must never change which variation a subject gets. + */ + @Nullable + private static List toFeatures(@Nullable final Object value) { + if (!(value instanceof List)) { + return null; + } + final List features = new ArrayList<>(); + for (final Object element : (List) value) { + final Feature feature = toFeature(element); + if (feature != null) { + features.add(feature); + } + } + return unmodifiableList(features); + } + + @Nullable + private static Feature toFeature(@Nullable final Object value) { + if (!(value instanceof Map)) { + return null; + } + final Map json = (Map) value; + final Object key = json.get("key"); + final Object featureValue = json.get("value"); + final List destinations = toDestinations(json.get("destinations")); + if (!(key instanceof String) + || ((String) key).isEmpty() + || !(featureValue instanceof String + || featureValue instanceof Double + || featureValue instanceof Boolean) + || destinations.isEmpty()) { + return null; + } + return new Feature((String) key, featureValue, destinations); + } + + /** Keeps every non-empty string, including destinations this SDK does not know yet. */ + private static List toDestinations(@Nullable final Object value) { + if (!(value instanceof List)) { + return emptyList(); + } + final List destinations = new ArrayList<>(); + for (final Object element : (List) value) { + if (element instanceof String && !((String) element).isEmpty()) { + destinations.add((String) element); + } + } + return unmodifiableList(destinations); } @Nullable diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index 36fd37e9e32..838282044f2 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -1,6 +1,7 @@ package com.datadog.openfeature.internal.ufc; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -11,6 +12,7 @@ import java.io.IOException; import java.io.InputStream; import java.time.Instant; +import java.util.List; import org.junit.jupiter.api.Test; import org.tabletest.junit.TableTest; @@ -170,6 +172,110 @@ void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int ex assertEquals(Integer.valueOf(expected), serialIdOf(config)); } + @Test + void parsesSplitFeatures() throws Exception { + final ServerConfiguration config = + deserialize( + configWithFeatures( + "[{\"key\": \"holdout.key\", \"value\": \"q4-global\", \"destinations\": [\"HOOK\"]}," + + " {\"key\": \"holdout.weight\", \"value\": 0.5," + + " \"destinations\": [\"EXPOSURE\", \"EVALUATION\"]}," + + " {\"key\": \"holdout.should_include_in_holdout_analysis\", \"value\": true," + + " \"destinations\": [\"HOOK\", \"SOME_FUTURE_DESTINATION\"]}]")); + + final List features = featuresOf(config); + assertEquals(3, features.size()); + assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); + assertFeature(features.get(1), "holdout.weight", 0.5, "EXPOSURE", "EVALUATION"); + assertFeature( + features.get(2), + "holdout.should_include_in_holdout_analysis", + true, + "HOOK", + "SOME_FUTURE_DESTINATION"); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + @Test + void parsesAbsentSplitFeaturesAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithFeatures(null)); + + assertNull(featuresOf(config)); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + @Test + void dropsMalformedSplitFeaturesAndKeepsTheFlag() throws Exception { + final String hook = ", \"destinations\": [\"HOOK\"]"; + final ServerConfiguration config = + deserialize( + configWithFeatures( + "[{\"key\": \"object-value\", \"value\": {\"nested\": true}" + + hook + + "}," + + " {\"key\": \"array-value\", \"value\": [1]" + + hook + + "}," + + " {\"key\": \"null-value\", \"value\": null" + + hook + + "}," + + " {\"key\": \"\", \"value\": \"empty key\"" + + hook + + "}," + + " {\"key\": 3, \"value\": \"numeric key\"" + + hook + + "}," + + " {\"value\": \"missing key\"" + + hook + + "}," + + " {\"key\": \"missing value\"" + + hook + + "}," + + " {\"key\": \"no-destinations\", \"value\": \"dropped\"}," + + " {\"key\": \"empty-destinations\", \"value\": \"dropped\", \"destinations\": []}," + + " {\"key\": \"destinations-not-a-list\", \"value\": \"dropped\"," + + " \"destinations\": \"HOOK\"}," + + " {\"key\": \"only-invalid-destinations\", \"value\": \"dropped\"," + + " \"destinations\": [1, \"\", null, {\"name\": \"HOOK\"}]}," + + " \"not-an-object\"," + + " {\"key\": \"holdout.key\", \"value\": \"q4-global\"," + + " \"destinations\": [1, \"HOOK\", \"\"], \"destination\": \"HOOK\"}]")); + + assertTrue(config.flags.containsKey("valid-flag")); + final List features = featuresOf(config); + assertEquals(1, features.size()); + assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); + } + + @Test + void ignoresSplitFeaturesThatAreNotAList() throws Exception { + final ServerConfiguration config = + deserialize(configWithFeatures("{\"holdout.key\": \"q4-global\"}")); + + assertTrue(config.flags.containsKey("valid-flag")); + assertNull(featuresOf(config)); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + private static void assertFeature( + final Feature feature, final String key, final Object value, final String... destinations) { + assertEquals(key, feature.key); + assertEquals(value, feature.value); + assertEquals(asList(destinations), feature.destinations); + } + + private static List featuresOf(final ServerConfiguration config) { + return config.flags.get("valid-flag").allocations.get(0).splits.get(0).features; + } + + /** Inserts the raw features JSON; null omits the key. */ + private static String configWithFeatures(final String featuresJson) throws IOException { + final String json = resource("split-features.json"); + return featuresJson == null + ? json.replace("\"features\": \"${features}\",", "") + : json.replace("\"${features}\"", featuresJson); + } + private static Integer serialIdOf(final ServerConfiguration config) { return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; } diff --git a/dd-openfeature/src/test/resources/remote-config/split-features.json b/dd-openfeature/src/test/resources/remote-config/split-features.json new file mode 100644 index 00000000000..d8ff27d02a0 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/split-features.json @@ -0,0 +1,35 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": 7, + "features": "${features}", + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} From 4fdfe4d82744132b2bda805874ec8b6933c05d7b Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 10:02:26 +0200 Subject: [PATCH 33/50] fix(ffe): Initialize FeatureFlaggingSource at native-image build time --- .../nativeimage/NativeImageGeneratorRunnerInstrumentation.java | 1 + 1 file changed, 1 insertion(+) diff --git a/dd-java-agent/instrumentation/graal/graal-native-image-20.0/src/main/java/datadog/trace/instrumentation/graal/nativeimage/NativeImageGeneratorRunnerInstrumentation.java b/dd-java-agent/instrumentation/graal/graal-native-image-20.0/src/main/java/datadog/trace/instrumentation/graal/nativeimage/NativeImageGeneratorRunnerInstrumentation.java index 8c8c6c06cb0..13998669551 100644 --- a/dd-java-agent/instrumentation/graal/graal-native-image-20.0/src/main/java/datadog/trace/instrumentation/graal/nativeimage/NativeImageGeneratorRunnerInstrumentation.java +++ b/dd-java-agent/instrumentation/graal/graal-native-image-20.0/src/main/java/datadog/trace/instrumentation/graal/nativeimage/NativeImageGeneratorRunnerInstrumentation.java @@ -85,6 +85,7 @@ public static void onEnter(@Advice.Argument(value = 0, readOnly = false) String[ + "datadog.trace.api.config.OtlpConfig$Protocol:build_time," + "datadog.trace.api.config.OtlpConfig$Temporality:build_time," + "datadog.trace.api.EventTracker:build_time," + + "datadog.trace.api.FeatureFlaggingSource:build_time," + "datadog.trace.api.InstrumenterConfig:build_time," + "datadog.trace.api.Functions:build_time," + "datadog.trace.api.Functions$NewInstance:build_time," From b993ef39dc2fd67dac334b0efe4f779da01fbb07 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 10:35:06 +0200 Subject: [PATCH 34/50] fix(ffe): Cap UFC document nesting depth The UFC parser reads the document recursively, including ignored fields, so a deeply nested document could overflow the stack with an error the configuration sources do not catch. Reject documents nested deeper than 32 levels, as Moshi capped them at 255 before. The cap is enforced by the reader rather than Jackson StreamReadConstraints, which jackson-core older than 2.15 (e.g. Spring Boot 2.7) does not provide. --- .../ufc/UniversalFlagConfigParser.java | 15 ++++++++--- .../ufc/UniversalFlagConfigParserTest.java | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index 3a699f51553..2721be5bfef 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -35,6 +35,9 @@ public final class UniversalFlagConfigParser { private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + /** UFC documents nest about ten levels deep; the cap bounds the recursive document reader. */ + private static final int MAX_NESTING_DEPTH = 32; + private static final JsonFactory JSON_FACTORY = new JsonFactory().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); @@ -82,7 +85,7 @@ private static Object readDocument(final byte[] content) throws IOException { if (parser.nextToken() == null) { throw new JsonParseException(parser, "Empty document"); } - final Object document = readValue(parser); + final Object document = readValue(parser, 1); if (parser.nextToken() != null) { throw new JsonParseException(parser, "Unexpected content after the document"); } @@ -90,21 +93,25 @@ private static Object readDocument(final byte[] content) throws IOException { } } - private static Object readValue(final JsonParser parser) throws IOException { + private static Object readValue(final JsonParser parser, final int depth) throws IOException { final JsonToken token = parser.currentToken(); + if ((token == JsonToken.START_OBJECT || token == JsonToken.START_ARRAY) + && depth > MAX_NESTING_DEPTH) { + throw new JsonParseException(parser, "Document nested deeper than " + MAX_NESTING_DEPTH); + } switch (token) { case START_OBJECT: final Map object = new LinkedHashMap<>(); while (parser.nextToken() == JsonToken.FIELD_NAME) { final String name = parser.currentName(); parser.nextToken(); - object.put(name, readValue(parser)); + object.put(name, readValue(parser, depth + 1)); } return object; case START_ARRAY: final List array = new ArrayList<>(); while (parser.nextToken() != JsonToken.END_ARRAY) { - array.add(readValue(parser)); + array.add(readValue(parser, depth + 1)); } return array; case VALUE_STRING: diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index 838282044f2..5afe68fa544 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -323,6 +323,21 @@ void rejectsTrailingJson() { assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); } + @Test + void acceptsNestingUpToTheLimit() throws Exception { + assertNotNull(deserialize(nestedConfig(31))); + } + + @Test + void rejectsNestingBeyondTheLimit() { + assertThrows(IOException.class, () -> deserialize(nestedConfig(32))); + assertThrows( + IOException.class, + () -> + UniversalFlagConfigParser.parseJsonApi( + ("{\"data\":" + nestedConfig(32) + "}").getBytes(UTF_8))); + } + @Test void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { final ServerConfiguration config = deserialize(resource("unknown-operator.json")); @@ -436,6 +451,18 @@ private static ServerConfiguration deserialize(final String json) throws Excepti return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); } + private static String nestedConfig(final int arrays) { + final StringBuilder json = new StringBuilder("{\"flags\":{},\"ignored\":"); + for (int i = 0; i < arrays; i++) { + json.append('['); + } + json.append('0'); + for (int i = 0; i < arrays; i++) { + json.append(']'); + } + return json.append('}').toString(); + } + private static String emptyConfig() throws IOException { return resource("empty-config.json"); } From 33db44b5304414dc968742aea07f6d4aabd3cda7 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 15:49:13 +0200 Subject: [PATCH 35/50] fix(ffe): Validate Feature Flags endpoint URLs Restore the endpoint validation the agent URL builders provided: - reject custom configuration URLs with a scheme other than http(s), - keep the custom URL query when adding the default configuration path, - reject a site that does not make a plain host name, so the API key cannot be sent to another host. --- .../delivery/CdnConfigurationSource.java | 10 ++++-- .../delivery/DirectIntakeTransport.java | 3 +- .../internal/delivery/SiteHost.java | 30 ++++++++++++++++++ .../delivery/CdnConfigurationSourceTest.java | 30 ++++++++++++------ .../delivery/DirectIntakeTransportTest.java | 9 ++++++ .../internal/delivery/SiteHostTest.java | 31 +++++++++++++++++++ 6 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/SiteHost.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/SiteHostTest.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java index f2ae167b055..167b59a2217 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -285,13 +285,17 @@ static URI endpoint(final Settings settings) { throw new IllegalArgumentException( "Invalid Feature Flags configuration source URL: " + configuredBaseUrl, e); } - if (parsed.getScheme() == null || parsed.getHost() == null) { + final String scheme = parsed.getScheme(); + if ((!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) + || parsed.getHost() == null) { throw new IllegalArgumentException( "Invalid Feature Flags configuration source URL: " + configuredBaseUrl); } final String path = parsed.getRawPath(); if (path == null || path.isEmpty() || "/".equals(path)) { - return parsed.resolve(UFC_RULES_BASED_SERVER_PATH); + final String query = parsed.getRawQuery(); + return parsed.resolve( + query == null ? UFC_RULES_BASED_SERVER_PATH : UFC_RULES_BASED_SERVER_PATH + "?" + query); } return parsed; } @@ -301,7 +305,7 @@ private static boolean isManagedEndpoint(final Settings settings) { } private static String managedHost(final Settings settings) { - return "ufc-server.ff-cdn." + settings.site(); + return SiteHost.of("ufc-server.ff-cdn", settings.site()); } static long retryDelayMillis( diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java index e20fb556a37..e77de40a0dd 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java @@ -45,6 +45,7 @@ public final class DirectIntakeTransport implements EventTransport { * * @param settings the SDK settings. * @return the transport, or {@code null} if no API key is configured. + * @throws IllegalArgumentException if the configured site is invalid. */ @Nullable public static DirectIntakeTransport create(final Settings settings) { @@ -53,7 +54,7 @@ public static DirectIntakeTransport create(final Settings settings) { return null; } return new DirectIntakeTransport( - URI.create("https://event-platform-intake." + settings.site() + "/api/v2/"), + URI.create("https://" + SiteHost.of("event-platform-intake", settings.site()) + "/api/v2/"), apiKey, HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).followRedirects(NEVER).build(), TimeUnit.MILLISECONDS::sleep); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/SiteHost.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/SiteHost.java new file mode 100644 index 00000000000..f12639c3ad2 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/SiteHost.java @@ -0,0 +1,30 @@ +package com.datadog.openfeature.internal.delivery; + +import java.net.URI; +import java.net.URISyntaxException; + +/** Builds the host of a Datadog endpoint from the configured site. */ +final class SiteHost { + private SiteHost() {} + + /** + * Builds the host of a Datadog endpoint. + * + * @param prefix the endpoint host prefix. + * @param site the configured Datadog site. + * @return the endpoint host. + * @throws IllegalArgumentException if the site does not make a plain host name. + */ + static String of(final String prefix, final String site) { + final String host = prefix + "." + site; + try { + final URI uri = new URI("https", null, host, -1, "/", null, null); + if (host.equalsIgnoreCase(uri.getHost())) { + return host; + } + } catch (final URISyntaxException ignored) { + // Reported below + } + throw new IllegalArgumentException("Invalid Datadog site: " + site); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java index 6e624d7352c..055293f6f6e 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java @@ -84,12 +84,23 @@ void encodesEnvInDatadogUfcCdnEndpoint() { CdnConfigurationSource.endpoint(TestSettings.of("env", "a b&c")).toString()); } + @Test + void rejectsInvalidSiteForDatadogUfcCdnEndpoint() { + assertThrows( + IllegalArgumentException.class, + () -> + CdnConfigurationSource.endpoint( + TestSettings.of("site", "datadoghq.com@other.example"))); + } + @TableTest({ - "scenario | baseUrl | endpoint ", - "host only | 'http://localhost:8080' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server'", - "root path | 'http://localhost:8080/' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server'", - "custom path | 'https://proxy.example/ufc.json' | 'https://proxy.example/ufc.json' ", - "trimmed | ' https://proxy.example/ufc ' | 'https://proxy.example/ufc' " + "scenario | baseUrl | endpoint ", + "host only | 'http://localhost:8080' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server' ", + "root path | 'http://localhost:8080/' | 'http://localhost:8080/api/v2/feature-flagging/config/rules-based/server' ", + "root query | 'https://proxy.example/?token=a%2Bb' | 'https://proxy.example/api/v2/feature-flagging/config/rules-based/server?token=a%2Bb'", + "host query | 'https://proxy.example?token=a' | 'https://proxy.example/api/v2/feature-flagging/config/rules-based/server?token=a' ", + "custom path | 'https://proxy.example/ufc.json' | 'https://proxy.example/ufc.json' ", + "trimmed | ' https://proxy.example/ufc ' | 'https://proxy.example/ufc' " }) void resolvesConfiguredAgentlessBaseUrl(final String baseUrl, final String endpoint) { assertEquals( @@ -104,10 +115,11 @@ void resolvesConfiguredAgentlessBaseUrl(final String baseUrl, final String endpo } @TableTest({ - "scenario | baseUrl ", - "no scheme | 'localhost' ", - "relative path | 'relative/ufc'", - "invalid | 'http://[::1' " + "scenario | baseUrl ", + "no scheme | 'localhost' ", + "relative path | 'relative/ufc' ", + "invalid | 'http://[::1' ", + "ftp scheme | 'ftp://example.com/ufc'" }) void rejectsInvalidConfiguredAgentlessBaseUrl(final String baseUrl) { final Settings settings = diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java index 465dbad7086..12ef8ec0d34 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java @@ -40,6 +40,15 @@ void requiresAnApiKey() { assertNotNull(DirectIntakeTransport.create(TestSettings.of("api-key", "secret"))); } + @Test + void rejectsInvalidSite() { + assertThrows( + IllegalArgumentException.class, + () -> + DirectIntakeTransport.create( + TestSettings.of("api-key", "secret", "site", "datadoghq.com@other.example"))); + } + @Test void postsJsonWithApiKeyAndTraceHeaders() throws Exception { this.server.enqueue(202, null); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/SiteHostTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/SiteHostTest.java new file mode 100644 index 00000000000..b47e3558004 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/SiteHostTest.java @@ -0,0 +1,31 @@ +package com.datadog.openfeature.internal.delivery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.tabletest.junit.TableTest; + +class SiteHostTest { + @TableTest({ + "scenario | site | host ", + "default | 'datadoghq.com' | 'intake.datadoghq.com' ", + "eu | 'datadoghq.eu' | 'intake.datadoghq.eu' ", + "subdomain | 'us3.datadoghq.com' | 'intake.us3.datadoghq.com'" + }) + void buildsHostFromSite(final String site, final String host) { + assertEquals(host, SiteHost.of("intake", site)); + } + + @TableTest({ + "scenario | site ", + "user info | 'datadoghq.com@other.example'", + "path | 'datadoghq.com/other' ", + "fragment | 'datadoghq.com#' ", + "port | 'datadoghq.com:8443' ", + "query | 'datadoghq.com?other' ", + "space | 'datadoghq com' " + }) + void rejectsSiteThatIsNotAHostName(final String site) { + assertThrows(IllegalArgumentException.class, () -> SiteHost.of("intake", site)); + } +} From 3f598552d862ded8d8ecd33ee0da6d24e9108535 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 16:08:44 +0200 Subject: [PATCH 36/50] fix(ffe): Parse boolean and integer settings like the agent Read boolean and integer settings from their raw value instead of a trimmed one: the agent reads a padded boolean as false and ignores a padded integer. Also skip blank values and integers that do not parse in favor of the next source, like the agent, so the SDK resolves the same settings with or without the agent attached. --- .../ConfigurationResolutionParityTest.java | 49 +++++++------ .../openfeature/internal/config/Settings.java | 71 ++++++++++++------- .../internal/config/SettingsTest.java | 59 +++++++++------ 3 files changed, 109 insertions(+), 70 deletions(-) diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java index 1fbd430dfcd..ea366116937 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java @@ -26,26 +26,29 @@ @ExtendWith(WithConfigExtension.class) class ConfigurationResolutionParityTest { @TableTest({ - "Scenario | Enabled | Source | Legacy | Expected ", - "defaults | | | | agentless ", - "enabled | true | | | agentless ", - "enabled as number | 1 | | | agentless ", - "disabled | false | | | ", - "disabled as number | 0 | | | ", - "disabled wins over source | false | remote_config | | ", - "remote config source | | remote_config | | remote_config", - "agentless source | | agentless | | agentless ", - "source is case insensitive | | Remote_Config | | remote_config", - "padded source is unsupported | | ' agentless ' | | ", - "unsupported source | | offline | | ", - "legacy enabled | | | true | remote_config", - "legacy disabled | | | false | ", - "source wins over legacy | | agentless | true | agentless ", - "source wins over legacy disabled | | remote_config | false | remote_config", - "enabled with legacy disabled | true | | false | ", - "uppercase boolean | TRUE | | TRUE | remote_config", - "invalid boolean reads as false | maybe | | | ", - "invalid legacy reads as false | | | maybe | " + "Scenario | Enabled | Source | Legacy | Expected ", + "defaults | | | | agentless ", + "enabled | true | | | agentless ", + "enabled as number | 1 | | | agentless ", + "disabled | false | | | ", + "disabled as number | 0 | | | ", + "disabled wins over source | false | remote_config | | ", + "remote config source | | remote_config | | remote_config", + "agentless source | | agentless | | agentless ", + "source is case insensitive | | Remote_Config | | remote_config", + "padded source is unsupported | | ' agentless ' | | ", + "unsupported source | | offline | | ", + "legacy enabled | | | true | remote_config", + "legacy disabled | | | false | ", + "source wins over legacy | | agentless | true | agentless ", + "source wins over legacy disabled | | remote_config | false | remote_config", + "enabled with legacy disabled | true | | false | ", + "uppercase boolean | TRUE | | TRUE | remote_config", + "invalid boolean reads as false | maybe | | | ", + "invalid legacy reads as false | | | maybe | ", + "padded enabled reads as false | ' true ' | | | ", + "padded number reads as false | ' 1 ' | | | ", + "padded legacy reads as false | | | ' true ' | " }) void agentAndSdkResolveTheSameConfiguration( final String enabled, final String source, final String legacy, final String expected) { @@ -68,13 +71,13 @@ private static String agentSource(final Map settings) { } /** - * Resolves the source like the SDK. Missing settings are reported as blank, so the SDK does not - * fall back to the system properties and environment of the test JVM. + * Resolves the source like the SDK. Missing settings fall back to the system properties injected + * for the agent, which hold the same values. */ private static String sdkSource(final Map settings) { final Connector connector = mock(Connector.class); when(connector.setting(anyString())) - .thenAnswer(invocation -> settings.getOrDefault(invocation.getArgument(0), "")); + .thenAnswer(invocation -> settings.get(invocation.getArgument(0))); return Settings.load(connector).configurationSource(); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index 0006e95d548..7dbab1cbd2f 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -15,6 +15,9 @@ public final class Settings { private static final Logger LOGGER = LoggerFactory.getLogger(Settings.class); + /** The setting sources: connector, system properties, then environment variables. */ + private static final int SOURCE_COUNT = 3; + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; @@ -186,58 +189,74 @@ public String version() { return nonBlank(get(VERSION)); } + /** Reads the first non-blank value, in source priority order, like the agent. */ @Nullable String get(final String key) { - String value = this.connector.setting(key); - if (value == null) { - value = this.systemProperties.apply("dd." + key); + for (int source = 0; source < SOURCE_COUNT; source++) { + final String value = value(key, source); + if (nonBlank(value) != null) { + return value; + } } - if (value == null) { - value = this.environment.apply(environmentVariableName(key)); + return null; + } + + /** + * Reads a setting from one source. + * + * @param key the setting key. + * @param source the source index, from the highest priority one. + * @return the raw setting value, or {@code null} if not set. + */ + @Nullable + private String value(final String key, final int source) { + switch (source) { + case 0: + return this.connector.setting(key); + case 1: + return this.systemProperties.apply("dd." + key); + default: + return this.environment.apply(environmentVariableName(key)); } - return value; } @Nullable private Boolean getBoolean(final String key) { - final String value = normalize(get(key)); + final String value = get(key); if (value == null) { return null; } - // Same parsing as the agent: invalid values are read as false. - if ("true".equals(value) || "1".equals(value)) { + // Same parsing as the agent: exact values, invalid ones are read as false. + if ("1".equals(value) || "true".equalsIgnoreCase(value)) { return Boolean.TRUE; } - if (!"false".equals(value) && !"0".equals(value)) { + if (!"0".equals(value) && !"false".equalsIgnoreCase(value)) { LOGGER.warn("Invalid boolean value for {}: {}", key, value); } return Boolean.FALSE; } + /** Reads the first value that parses, in source priority order, like the agent. */ private int getPositiveInt(final String key, final int defaultValue) { - final String value = nonBlank(get(key)); - if (value == null) { - return defaultValue; - } - try { - final int parsed = Integer.parseInt(value); - return parsed > 0 ? parsed : defaultValue; - } catch (final NumberFormatException e) { - LOGGER.warn("Invalid value for {}: {}", key, value); - return defaultValue; + for (int source = 0; source < SOURCE_COUNT; source++) { + final String value = value(key, source); + if (nonBlank(value) == null) { + continue; + } + try { + final int parsed = Integer.parseInt(value); + return parsed > 0 ? parsed : defaultValue; + } catch (final NumberFormatException e) { + LOGGER.warn("Invalid value for {}: {}", key, value); + } } + return defaultValue; } static String environmentVariableName(final String key) { return "DD_" + key.replace('.', '_').replace('-', '_').toUpperCase(Locale.ROOT); } - @Nullable - private static String normalize(@Nullable final String value) { - final String trimmed = nonBlank(value); - return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT); - } - /** * Normalizes the configuration source like the agent enum parsing: case insensitive, with no * trimming of surrounding spaces. diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java index cf1f09f18dd..2cab13382c7 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -44,21 +44,23 @@ void mapsKeysToEnvironmentVariables(final String key, final String variable) { } @TableTest({ - "scenario | enabled | source | legacy | resolved ", - "default | | | | 'agentless' ", - "explicit agentless | | 'agentless' | | 'agentless' ", - "explicit remote config | | 'remote_config' | | 'remote_config'", - "case insensitive source | | 'Remote_Config' | | 'remote_config'", - "padded source | | ' agentless ' | | ", - "unsupported source | | 'offline' | | ", - "blank source | | ' ' | | 'agentless' ", - "disabled wins over source | 'false' | 'remote_config' | | ", - "enabled keeps default | 'true' | | | 'agentless' ", - "legacy enabled selects rc | | | 'true' | 'remote_config'", - "legacy disabled disables | | | 'false' | ", - "explicit source wins over legacy | | 'agentless' | 'false' | 'agentless' ", - "invalid enabled reads as false | 'maybe' | | | ", - "numeric disabled | '0' | | | " + "scenario | enabled | source | legacy | resolved ", + "default | | | | 'agentless' ", + "explicit agentless | | 'agentless' | | 'agentless' ", + "explicit remote config | | 'remote_config' | | 'remote_config'", + "case insensitive source | | 'Remote_Config' | | 'remote_config'", + "padded source | | ' agentless ' | | ", + "unsupported source | | 'offline' | | ", + "blank source | | ' ' | | 'agentless' ", + "disabled wins over source | 'false' | 'remote_config' | | ", + "enabled keeps default | 'true' | | | 'agentless' ", + "legacy enabled selects rc | | | 'true' | 'remote_config'", + "legacy disabled disables | | | 'false' | ", + "explicit source wins over legacy | | 'agentless' | 'false' | 'agentless' ", + "invalid enabled reads as false | 'maybe' | | | ", + "numeric disabled | '0' | | | ", + "padded enabled reads as false | ' true ' | | | ", + "padded legacy reads as false | | | ' true ' | " }) void resolvesConfigurationSource( final String enabled, final String source, final String legacy, final String resolved) { @@ -88,12 +90,13 @@ void appliesDefaults() { } @TableTest({ - "scenario | value | interval", - "valid | '10' | 10 ", - "zero | '0' | 30 ", - "negative | '-5' | 30 ", - "invalid | 'ten' | 30 ", - "blank | ' ' | 30 " + "scenario | value | interval", + "valid | '10' | 10 ", + "zero | '0' | 30 ", + "negative | '-5' | 30 ", + "invalid | 'ten' | 30 ", + "blank | ' ' | 30 ", + "padded | ' 10 ' | 30 " }) void fallsBackToDefaultPollIntervalOnInvalidValues(final String value, final int interval) { assertEquals( @@ -102,6 +105,20 @@ void fallsBackToDefaultPollIntervalOnInvalidValues(final String value, final int .agentlessPollIntervalSeconds()); } + @Test + void fallsBackToLowerPrioritySourcesOnBlankOrInvalidValues() { + final Map properties = new HashMap<>(); + properties.put("dd.flagging.evaluation.counts.enabled", " "); + properties.put("dd.feature.flags.configuration.source.agentless.poll.interval.seconds", "ten"); + final Map environment = new HashMap<>(); + environment.put("DD_FLAGGING_EVALUATION_COUNTS_ENABLED", "false"); + environment.put("DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS", "10"); + final Settings settings = new Settings(Connector.NONE, properties::get, environment::get); + + assertFalse(settings.isEvaluationCountsEnabled()); + assertEquals(10, settings.agentlessPollIntervalSeconds()); + } + @Test void trimsAndIgnoresBlankValues() { final Settings settings = From 60972638b3e75dcb86e0a2e1f546f9372d045c2b Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 16:08:45 +0200 Subject: [PATCH 37/50] fix(ffe): Report only parsing failures as malformed Remote Configuration Keep the configuration listener call out of the parsing error handling, so a listener failure is not logged as a malformed configuration. --- .../internal/delivery/RemoteConfigurationService.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java index 04c0722e9a8..8ed5f9ce4f1 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java @@ -32,13 +32,15 @@ public synchronized void start(final Consumer listener) { listener.accept(null); return; } + final ServerConfiguration configuration; try { - final ServerConfiguration configuration = UniversalFlagConfigParser.parse(content); - if (configuration != null) { - listener.accept(configuration); - } + configuration = UniversalFlagConfigParser.parse(content); } catch (final IOException | RuntimeException e) { LOGGER.debug("Remote Configuration returned a malformed flag configuration", e); + return; + } + if (configuration != null) { + listener.accept(configuration); } }); } From 18cc8acb0b689739bfc7df88cdcfdfd240602b5c Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 16:08:46 +0200 Subject: [PATCH 38/50] fix(ffe): Isolate Feature Flags configuration listeners Keep notifying the other configuration listeners when one of them fails. --- .../internal/FeatureFlagsRuntime.java | 6 ++++- .../internal/FeatureFlagsRuntimeTest.java | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java index 57896c89f7f..b787eb0488c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -181,7 +181,11 @@ void stop() { private void onConfiguration(@Nullable final ServerConfiguration configuration) { this.configuration = configuration; for (final Consumer listener : this.listeners) { - listener.accept(configuration); + try { + listener.accept(configuration); + } catch (final RuntimeException e) { + LOGGER.warn("Feature Flags configuration listener failed", e); + } } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java index bc8545cd387..6c2284153a7 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -129,6 +129,29 @@ void removedListenersAreNotNotified() { verify(listener, org.mockito.Mockito.never()).accept(org.mockito.ArgumentMatchers.any()); } + @Test + void failingListenerDoesNotStarveOtherListeners() { + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + final List received = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener( + configuration -> { + throw new IllegalStateException("listener failure"); + }); + runtime.addConfigurationListener(received::add); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + } finally { + runtime.stop(); + } + + assertEquals(1, received.size()); + } + @Test void sharedRuntimeIsReferenceCounted() { final TestConfigurationSource source = new TestConfigurationSource(); From b5394ad1068c94c8016180d6643d6c299b7150da Mon Sep 17 00:00:00 2001 From: Sarah Chen Date: Wed, 14 Oct 2026 19:12:20 -0400 Subject: [PATCH 39/50] fix(ffe): Fix HTTP timeouts, cancellations, and retries --- .../delivery/CdnConfigurationSource.java | 48 ++-- .../delivery/DirectIntakeTransport.java | 50 ++++- .../internal/delivery/HttpCall.java | 124 +++++++++++ .../internal/delivery/Retries.java | 67 ++++-- .../delivery/CdnConfigurationSourceTest.java | 112 +++++++++- .../delivery/DirectIntakeTransportTest.java | 94 +++++++- .../internal/delivery/HttpCallTest.java | 206 ++++++++++++++++++ .../internal/delivery/StallingHttpServer.java | 94 ++++++++ 8 files changed, 745 insertions(+), 50 deletions(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/HttpCall.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/HttpCallTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/StallingHttpServer.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java index 167b59a2217..fa1b669db02 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -16,6 +16,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -52,11 +53,12 @@ public final class CdnConfigurationSource implements ConfigurationService { private final DoubleSupplier jitter; private final Object lifecycleLock = new Object(); private final AtomicBoolean polling = new AtomicBoolean(); + private final CountDownLatch closedSignal = new CountDownLatch(1); + private HttpCall activeCall; private volatile Consumer listener; private volatile boolean closed; private volatile boolean started; private volatile ScheduledFuture scheduledPoll; - private volatile Thread pollingThread; private volatile String etag; private long lastWarningNanos; @@ -72,7 +74,7 @@ public CdnConfigurationSource(final Settings settings, final RuntimeServices ser .build(), Executors.newSingleThreadScheduledExecutor( task -> services.newThread("configuration", task)), - TimeUnit.MILLISECONDS::sleep, + null, () -> ThreadLocalRandom.current().nextDouble(1 - RETRY_JITTER, 1 + RETRY_JITTER)); } @@ -83,7 +85,7 @@ public CdnConfigurationSource(final Settings settings, final RuntimeServices ser final Duration requestTimeout, final HttpClient httpClient, final ScheduledExecutorService executor, - final Retries.Sleeper retrySleeper, + @Nullable final Retries.Sleeper retrySleeper, final DoubleSupplier jitter) { this.endpoint = endpoint; this.apiKey = apiKey; @@ -91,7 +93,10 @@ public CdnConfigurationSource(final Settings settings, final RuntimeServices ser this.requestTimeout = requestTimeout; this.httpClient = httpClient; this.executor = executor; - this.retrySleeper = retrySleeper; + this.retrySleeper = + retrySleeper != null + ? retrySleeper + : delay -> this.closedSignal.await(delay, TimeUnit.MILLISECONDS); this.jitter = jitter; } @@ -122,6 +127,7 @@ public void start(final Consumer listener) { @Override public void close() { final ScheduledFuture poll; + final HttpCall call; synchronized (this.lifecycleLock) { if (this.closed) { return; @@ -129,14 +135,16 @@ public void close() { this.closed = true; poll = this.scheduledPoll; this.scheduledPoll = null; + call = this.activeCall; + this.activeCall = null; + } + this.closedSignal.countDown(); + if (call != null) { + call.cancel(); } if (poll != null) { poll.cancel(true); } - final Thread activePollingThread = this.pollingThread; - if (activePollingThread != null) { - activePollingThread.interrupt(); - } this.executor.shutdownNow(); } @@ -154,11 +162,12 @@ boolean pollOnce() { if (this.closed || !this.polling.compareAndSet(false, true)) { return false; } - this.pollingThread = Thread.currentThread(); try { return fetchAndApply(); } finally { - this.pollingThread = null; + synchronized (this.lifecycleLock) { + this.activeCall = null; + } this.polling.set(false); } } @@ -232,12 +241,23 @@ private boolean apply(final HttpResponse response) { } private HttpResponse fetchWithRetries() throws IOException, InterruptedException { + final HttpRequest request = buildRequest(); return Retries.send( - this.httpClient, - buildRequest(), - HttpResponse.BodyHandlers.ofByteArray(), + () -> { + synchronized (this.lifecycleLock) { + if (this.closed) { + throw new IOException("Feature Flags configuration source is closed"); + } + this.activeCall = + new HttpCall<>(this.httpClient, request, HttpResponse.BodyHandlers.ofByteArray()); + return this.activeCall; + } + }, + (response, attempt) -> + response == null || isRetryableStatus(response.statusCode()) + ? retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble()) + : -1, MAX_ATTEMPTS, - attempt -> retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble()), this.retrySleeper, () -> this.closed); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java index e77de40a0dd..84e60ff61bc 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java @@ -6,10 +6,12 @@ import com.datadog.openfeature.internal.config.Settings; import com.datadog.openfeature.internal.connector.EventTransport; import java.io.IOException; +import java.net.ConnectException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; import java.time.Duration; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; @@ -75,11 +77,11 @@ public void post(final String route, final byte[] json) throws IOException { try { status = Retries.send( - this.httpClient, - request, - HttpResponse.BodyHandlers.discarding(), + () -> + new HttpCall<>( + this.httpClient, request, HttpResponse.BodyHandlers.discarding()), + new IntakeRetryPolicy(), MAX_RETRIES + 1, - attempt -> INITIAL_RETRY_DELAY_MILLIS << (attempt - 1), this.retrySleeper, () -> false) .statusCode(); @@ -91,4 +93,44 @@ public void post(final String route, final byte[] json) throws IOException { throw new IOException("Request to " + route + " returned error response " + status); } } + + private static final class IntakeRetryPolicy implements Retries.Policy { + private boolean rateLimitRetry; + + @Override + public boolean shouldRetry(final IOException error) { + return error instanceof ConnectException || error instanceof HttpTimeoutException; + } + + @Override + public long retryDelayMillis(@Nullable final HttpResponse response, final int attempt) { + if (this.rateLimitRetry) { + return -1; + } + if (response != null) { + final int status = response.statusCode(); + if (status == 429) { + final String reset = response.headers().firstValue("X-RateLimit-Reset").orElse(null); + if (reset != null) { + try { + final long seconds = Long.parseLong(reset); + if (seconds > 10) { + return -1; + } + if (seconds >= 0) { + this.rateLimitRetry = true; + return TimeUnit.SECONDS.toMillis(seconds) + + ThreadLocalRandom.current().nextInt(401); + } + } catch (final NumberFormatException ignored) { + // An invalid reset header falls back to exponential backoff. + } + } + } else if (status < 500 || status > 599) { + return -1; + } + } + return INITIAL_RETRY_DELAY_MILLIS << (attempt - 1); + } + } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/HttpCall.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/HttpCall.java new file mode 100644 index 00000000000..26abe79e754 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/HttpCall.java @@ -0,0 +1,124 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.lang.Math.max; +import static java.util.concurrent.TimeUnit.NANOSECONDS; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeoutException; + +/** A cancellable HTTP attempt whose timeout includes response body consumption. */ +final class HttpCall { + private final long startedNanos = System.nanoTime(); + private final long timeoutNanos; + private final CompletableFuture> response; + private Flow.Subscription subscription; + private boolean cancelled; + + HttpCall( + final HttpClient client, + final HttpRequest request, + final HttpResponse.BodyHandler bodyHandler) { + this.timeoutNanos = request.timeout().orElseThrow().toNanos(); + this.response = + client.sendAsync(request, info -> new CancellableBodySubscriber(bodyHandler.apply(info))); + } + + /** + * Waits for the complete response. + * + * @return the response, including its consumed body. + * @throws IOException if the request fails, times out, or is cancelled. + * @throws InterruptedException if the caller is interrupted. + */ + HttpResponse execute() throws IOException, InterruptedException { + try { + return this.response.get( + max(0, this.timeoutNanos - (System.nanoTime() - this.startedNanos)), NANOSECONDS); + } catch (final TimeoutException e) { + cancel(); + throw new HttpTimeoutException("HTTP response timed out"); + } catch (final InterruptedException e) { + cancel(); + throw e; + } catch (final CancellationException e) { + throw new IOException("HTTP request cancelled", e); + } catch (final ExecutionException e) { + final Throwable cause = e.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + if (cause instanceof CancellationException) { + throw new IOException("HTTP request cancelled", cause); + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new IOException(cause); + } + } + + /** Cancels the active exchange and any response body subscription. */ + synchronized void cancel() { + if (!this.cancelled && this.response.cancel(true)) { + this.cancelled = true; + if (this.subscription != null) { + this.subscription.cancel(); + } + } + } + + private synchronized void subscribe(final Flow.Subscription subscription) { + this.subscription = subscription; + if (this.cancelled) { + subscription.cancel(); + } + } + + private final class CancellableBodySubscriber implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber delegate; + + private CancellableBodySubscriber(final HttpResponse.BodySubscriber delegate) { + this.delegate = delegate; + } + + @Override + public CompletionStage getBody() { + return this.delegate.getBody(); + } + + @Override + public void onSubscribe(final Flow.Subscription subscription) { + this.delegate.onSubscribe(subscription); + subscribe(subscription); + } + + @Override + public void onNext(final List buffers) { + this.delegate.onNext(buffers); + } + + @Override + public void onError(final Throwable error) { + this.delegate.onError(error); + } + + @Override + public void onComplete() { + this.delegate.onComplete(); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java index fd6e22b7ee9..7c1544e4708 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/Retries.java @@ -2,11 +2,9 @@ import java.io.IOException; import java.net.HttpURLConnection; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.function.BooleanSupplier; -import java.util.function.IntToLongFunction; +import javax.annotation.Nullable; /** Sends HTTP requests, retrying the failures that can succeed on a later attempt. */ final class Retries { @@ -16,11 +14,9 @@ private Retries() {} * Sends a request until it gets a non-retryable response, the attempts are exhausted, or the * caller gives up. * - * @param httpClient the client to send the request with. - * @param request the request to send. - * @param bodyHandler the response body handler. + * @param callFactory creates a new call for each attempt. + * @param policy the retry rules and delays. * @param maxAttempts the maximum number of attempts. - * @param retryDelayMillis the delay before the next attempt, given the failed attempt number. * @param sleeper the sleeper waiting between attempts. * @param cancelled whether the caller gave up on the request. * @param the response body type. @@ -29,36 +25,48 @@ private Retries() {} * @throws InterruptedException if interrupted while sending or waiting between attempts. */ static HttpResponse send( - final HttpClient httpClient, - final HttpRequest request, - final HttpResponse.BodyHandler bodyHandler, + final CallFactory callFactory, + final Policy policy, final int maxAttempts, - final IntToLongFunction retryDelayMillis, final Sleeper sleeper, final BooleanSupplier cancelled) throws IOException, InterruptedException { - int attempt = 0; - while (true) { - attempt++; + for (int attempt = 1; ; attempt++) { + if (cancelled.getAsBoolean()) { + throw new IOException("HTTP request cancelled"); + } + if (Thread.interrupted()) { + throw new InterruptedException(); + } + long delayMillis; try { - final HttpResponse response = httpClient.send(request, bodyHandler); - if (!isRetryableStatus(response.statusCode()) - || attempt == maxAttempts - || cancelled.getAsBoolean()) { + final HttpResponse response = callFactory.newCall().execute(); + if (attempt == maxAttempts || cancelled.getAsBoolean()) { + return response; + } + delayMillis = policy.retryDelayMillis(response, attempt); + if (delayMillis < 0) { return response; } } catch (final IOException e) { if (attempt == maxAttempts + || !policy.shouldRetry(e) || cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) { throw e; } + delayMillis = policy.retryDelayMillis(null, attempt); + if (delayMillis < 0) { + throw e; + } } - sleeper.sleep(retryDelayMillis.applyAsLong(attempt)); + sleeper.sleep(delayMillis); } } /** + * Identifies retryable CDN responses. + * * @param status the HTTP response status. * @return whether a request that got this status can succeed on a later attempt. */ @@ -68,6 +76,27 @@ static boolean isRetryableStatus(final int status) { || (status >= 500 && status <= 599); } + @FunctionalInterface + interface CallFactory { + HttpCall newCall() throws IOException; + } + + @FunctionalInterface + interface Policy { + /** + * Computes the delay before retrying an attempt. + * + * @param response the failed response, or {@code null} for an I/O failure. + * @param attempt the failed attempt number. + * @return the delay in milliseconds, or a negative value to stop retrying. + */ + long retryDelayMillis(@Nullable HttpResponse response, int attempt); + + default boolean shouldRetry(final IOException error) { + return true; + } + } + @FunctionalInterface interface Sleeper { void sleep(long delayMillis) throws InterruptedException; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java index 055293f6f6e..838ef1e4ae6 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java @@ -29,12 +29,15 @@ import java.time.Duration; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.FutureTask; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.tabletest.junit.TableTest; class CdnConfigurationSourceTest { @@ -419,8 +422,9 @@ void startAfterCloseDoesNotPoll() { } @Test - void closeInterruptsRetryBackoff() throws Exception { + void closeWakesRetryBackoffWithoutInterruptingCaller() throws Exception { this.server.otherwise(503, null); + final CountDownLatch backoff = new CountDownLatch(1); final CdnConfigurationSource source = new CdnConfigurationSource( this.server.uri("/ufc"), @@ -429,19 +433,105 @@ void closeInterruptsRetryBackoff() throws Exception { Duration.ofSeconds(5), HttpClient.newHttpClient(), mock(ScheduledExecutorService.class), - MILLISECONDS::sleep, - () -> 1.0); - final boolean[] result = {true}; - final Thread poller = new Thread(() -> result[0] = source.pollOnce()); + null, + () -> { + backoff.countDown(); + return 1.0; + }); + final boolean[] result = {true, true}; + final Thread poller = + new Thread( + () -> { + result[0] = source.pollOnce(); + result[1] = Thread.currentThread().isInterrupted(); + }); + poller.setDaemon(true); poller.start(); - this.server.received().poll(5, SECONDS); + try { + assertTrue(backoff.await(5, SECONDS)); + source.close(); + poller.join(SECONDS.toMillis(5)); - source.close(); - poller.join(SECONDS.toMillis(5)); + assertFalse(poller.isAlive(), "close must wake the retry backoff"); + assertFalse(result[0]); + assertFalse(result[1], "close must not interrupt the caller"); + assertEquals(1, this.server.requests().size()); + } finally { + source.close(); + poller.interrupt(); + poller.join(SECONDS.toMillis(5)); + } + } - assertFalse(poller.isAlive(), "close must interrupt the retry backoff"); - assertFalse(result[0]); - assertEquals(1, this.server.requests().size()); + @Test + @Timeout(15) + void stalledBodyDoesNotBlockPollingIndefinitely() throws Exception { + try (StallingHttpServer stalled = new StallingHttpServer(true)) { + final CdnConfigurationSource source = + new CdnConfigurationSource( + stalled.uri(), + null, + POLL_INTERVAL_MILLIS, + Duration.ofMillis(500), + HttpClient.newHttpClient(), + mock(ScheduledExecutorService.class), + this.sleeps::add, + () -> 1.0); + final FutureTask polling = new FutureTask<>(source::pollOnce); + final Thread caller = new Thread(polling, "configuration-poll"); + caller.setDaemon(true); + caller.start(); + try { + stalled.awaitRequest(); + + assertFalse(polling.get(5, SECONDS)); + assertTrue(stalled.awaitDisconnect()); + assertEquals(CdnConfigurationSource.MAX_ATTEMPTS - 1, this.sleeps.size()); + } finally { + source.close(); + polling.cancel(true); + caller.join(SECONDS.toMillis(5)); + } + } + } + + @Test + @Timeout(15) + void closeCancelsInitialPollWithoutInterruptingCaller() throws Exception { + try (StallingHttpServer stalled = new StallingHttpServer(true)) { + final CdnConfigurationSource source = + new CdnConfigurationSource( + stalled.uri(), + null, + POLL_INTERVAL_MILLIS, + Duration.ofMinutes(1), + HttpClient.newHttpClient(), + mock(ScheduledExecutorService.class), + null, + () -> 1.0); + final FutureTask initialization = + new FutureTask<>( + () -> { + source.start(this.dispatched::add); + return Thread.currentThread().isInterrupted(); + }); + final Thread caller = new Thread(initialization, "provider-initialization"); + caller.setDaemon(true); + caller.start(); + try { + stalled.awaitRequest(); + source.close(); + + assertFalse(initialization.get(5, SECONDS), "close must not interrupt initialization"); + assertTrue(stalled.awaitDisconnect()); + assertTrue(this.dispatched.isEmpty()); + assertFalse(source.pollOnce()); + } finally { + source.close(); + initialization.cancel(true); + caller.join(SECONDS.toMillis(5)); + } + } } private CdnConfigurationSource source() { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java index 12ef8ec0d34..8ff26333df8 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransportTest.java @@ -5,22 +5,34 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import com.datadog.openfeature.internal.LocalHttpServer; import com.datadog.openfeature.internal.config.TestSettings; import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.ConnectException; import java.net.InetAddress; import java.net.ServerSocket; import java.net.URI; +import java.net.UnknownHostException; import java.net.http.HttpClient; +import java.net.http.HttpTimeoutException; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; +import javax.net.ssl.SSLException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.tabletest.junit.TableTest; +import org.tabletest.junit.TypeConverter; -class DirectIntakeTransportTest { +public class DirectIntakeTransportTest { private LocalHttpServer server; private final List sleeps = new CopyOnWriteArrayList<>(); @@ -69,7 +81,6 @@ void postsJsonWithApiKeyAndTraceHeaders() throws Exception { @TableTest({ "scenario | status", - "client timeout | 408 ", "server error | 500 ", "unavailable | 503 ", "too many requests | 429 " @@ -88,6 +99,7 @@ void retriesRetryableStatus(final int status) throws Exception { "bad request | 400 ", "forbidden | 403 ", "not found | 404 ", + "timeout | 408 ", "redirect | 302 " }) void doesNotRetryOtherFailures(final int status) { @@ -108,6 +120,84 @@ void givesUpAfterMaxRetries() { assertEquals(DirectIntakeTransport.MAX_RETRIES, this.sleeps.size()); } + @TableTest({ + "scenario | reset | requests | minDelay | maxDelay", + "immediate | 0 | 2 | 0 | 400 ", + "one second | 1 | 2 | 1000 | 1400 ", + "maximum wait | 10 | 2 | 10000 | 10400 ", + "too long | 11 | 1 | 0 | 0 ", + "overflow | 9223372036854775807 | 1 | 0 | 0 ", + "invalid | invalid | 3 | 100 | 100 ", + "negative | -2 | 3 | 100 | 100 ", + "empty | '' | 3 | 100 | 100 " + }) + void honorsRateLimitReset( + final String reset, final int requests, final long minDelay, final long maxDelay) + throws Exception { + this.server + .enqueue(429, null, "X-RateLimit-Reset", reset) + .enqueue(503, null) + .enqueue(200, null); + + if (requests == 3) { + transport().post("exposures", new byte[] {'{', '}'}); + } else { + assertThrows(IOException.class, () -> transport().post("exposures", new byte[] {'{', '}'})); + } + + assertEquals(requests, this.server.requests().size()); + assertEquals(requests - 1, this.sleeps.size()); + if (!this.sleeps.isEmpty()) { + assertTrue(this.sleeps.get(0) >= minDelay && this.sleeps.get(0) <= maxDelay); + } + } + + @TableTest({ + "scenario | failure | attempts", + "connection | connection | 6 ", + "timeout | timeout | 6 ", + "TLS failure | tls | 1 ", + "DNS failure | dns | 1 ", + "interrupted IO | interrupted | 1 ", + "other IO | other | 1 " + }) + void retriesOnlyConnectionAndTimeoutFailures(final IOException failure, final int attempts) + throws Exception { + final HttpClient client = mock(HttpClient.class); + doReturn(CompletableFuture.failedFuture(failure)).when(client).sendAsync(any(), any()); + doThrow(failure).when(client).send(any(), any()); + final DirectIntakeTransport transport = + new DirectIntakeTransport(this.server.uri("/api/v2/"), "secret", client, this.sleeps::add); + + assertThrows(failure.getClass(), () -> transport.post("exposures", new byte[] {'{', '}'})); + + assertEquals(attempts - 1, this.sleeps.size()); + } + + /** + * Creates an I/O failure for a retry scenario. + * + * @param value the failure kind. + * @return the failure. + */ + @TypeConverter + public static IOException failure(final String value) { + switch (value) { + case "connection": + return new ConnectException("connection refused"); + case "timeout": + return new HttpTimeoutException("request timed out"); + case "tls": + return new SSLException("TLS handshake failed"); + case "dns": + return new UnknownHostException("unknown host"); + case "interrupted": + return new InterruptedIOException("interrupted"); + default: + return new IOException("I/O failure"); + } + } + @Test void retriesConnectionFailures() throws Exception { final URI unreachable; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/HttpCallTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/HttpCallTest.java new file mode 100644 index 00000000000..c8452bb8ae4 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/HttpCallTest.java @@ -0,0 +1,206 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import com.datadog.openfeature.internal.LocalHttpServer; +import java.io.IOException; +import java.net.ConnectException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.tabletest.junit.TableTest; + +@Timeout(15) +class HttpCallTest { + @TableTest({ + "scenario | discard", + "CDN | false ", + "intake | true " + }) + void timesOutStalledBodyAndClosesConnection(final boolean discard) throws Exception { + try (StallingHttpServer server = new StallingHttpServer(true)) { + final CountDownLatch handlerCalled = new CountDownLatch(1); + final HttpResponse.BodyHandler handler = bodyHandler(discard); + final HttpCall call = + new HttpCall<>( + HttpClient.newHttpClient(), + request(server, Duration.ofSeconds(1)), + info -> { + handlerCalled.countDown(); + return handler.apply(info); + }); + + assertTrue(handlerCalled.await(5, SECONDS)); + assertThrows(HttpTimeoutException.class, call::execute); + assertTrue(server.awaitDisconnect()); + } + } + + @Test + void timesOutStalledHeaders() throws Exception { + try (StallingHttpServer server = new StallingHttpServer(false)) { + final HttpCall call = + new HttpCall<>( + HttpClient.newHttpClient(), + request(server, Duration.ofMillis(500)), + HttpResponse.BodyHandlers.ofByteArray()); + + server.awaitRequest(); + assertThrows(HttpTimeoutException.class, call::execute); + server.sendHeaders(); + assertTrue(server.awaitDisconnect()); + } + } + + @Test + void cancellationBeforeHeadersCancelsLateSubscription() throws Exception { + try (StallingHttpServer server = new StallingHttpServer(false)) { + final HttpCall call = + new HttpCall<>( + HttpClient.newHttpClient(), + request(server, Duration.ofSeconds(10)), + HttpResponse.BodyHandlers.ofByteArray()); + + server.awaitRequest(); + call.cancel(); + assertThrows(IOException.class, call::execute); + server.sendHeaders(); + assertTrue(server.awaitDisconnect()); + } + } + + @Test + void cancellationDuringHandlerCreationCancelsLateSubscription() throws Exception { + final CountDownLatch handlerCalled = new CountDownLatch(1); + final CountDownLatch createSubscriber = new CountDownLatch(1); + try (StallingHttpServer server = new StallingHttpServer(true)) { + final HttpCall call = + new HttpCall<>( + HttpClient.newHttpClient(), + request(server, Duration.ofSeconds(10)), + info -> { + handlerCalled.countDown(); + try { + if (!createSubscriber.await(5, SECONDS)) { + throw new IllegalStateException("Subscriber creation was not released"); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + return HttpResponse.BodySubscribers.ofByteArray(); + }); + + assertTrue(handlerCalled.await(5, SECONDS)); + call.cancel(); + createSubscriber.countDown(); + assertThrows(IOException.class, call::execute); + assertTrue(server.awaitDisconnect()); + } finally { + createSubscriber.countDown(); + } + } + + @Test + void interruptionClosesConnection() throws Exception { + try (StallingHttpServer server = new StallingHttpServer(true)) { + final HttpCall call = + new HttpCall<>( + HttpClient.newHttpClient(), + request(server, Duration.ofSeconds(10)), + HttpResponse.BodyHandlers.ofByteArray()); + + server.awaitRequest(); + Thread.currentThread().interrupt(); + try { + assertThrows(InterruptedException.class, call::execute); + } finally { + Thread.interrupted(); + } + assertTrue(server.awaitDisconnect()); + } + } + + @Test + void preservesIoExceptionType() { + final HttpClient client = mock(HttpClient.class); + final ConnectException failure = new ConnectException("connection refused"); + doReturn(CompletableFuture.failedFuture(failure)).when(client).sendAsync(any(), any()); + final HttpCall call = + new HttpCall<>( + client, + HttpRequest.newBuilder(URI.create("http://localhost/")) + .timeout(Duration.ofSeconds(1)) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertSame(failure, assertThrows(ConnectException.class, call::execute)); + } + + @Test + void normalizesWrappedCancellation() { + final HttpClient client = mock(HttpClient.class); + final CancellationException cancelled = new CancellationException("Request cancelled"); + doReturn(CompletableFuture.failedFuture(new CompletionException(cancelled))) + .when(client) + .sendAsync(any(), any()); + final HttpCall call = + new HttpCall<>( + client, + HttpRequest.newBuilder(URI.create("http://localhost/")) + .timeout(Duration.ofSeconds(1)) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertSame(cancelled, assertThrows(IOException.class, call::execute).getCause()); + } + + @Test + void successfulResponsesRemainUsableAfterCancel() throws Exception { + try (LocalHttpServer server = new LocalHttpServer()) { + server.enqueue(200, "first").enqueue(200, "second"); + final HttpClient client = HttpClient.newHttpClient(); + final HttpRequest request = + HttpRequest.newBuilder(server.uri("/")).timeout(Duration.ofSeconds(5)).build(); + final HttpCall first = + new HttpCall<>(client, request, HttpResponse.BodyHandlers.ofByteArray()); + + final byte[] body = first.execute().body(); + first.cancel(); + + assertArrayEquals("first".getBytes(UTF_8), body); + assertArrayEquals( + "second".getBytes(UTF_8), + new HttpCall<>(client, request, HttpResponse.BodyHandlers.ofByteArray()) + .execute() + .body()); + } + } + + private static HttpRequest request(final StallingHttpServer server, final Duration timeout) { + return HttpRequest.newBuilder(server.uri()).timeout(timeout).build(); + } + + private static HttpResponse.BodyHandler bodyHandler(final boolean discard) { + return discard + ? HttpResponse.BodyHandlers.discarding() + : HttpResponse.BodyHandlers.ofByteArray(); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/StallingHttpServer.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/StallingHttpServer.java new file mode 100644 index 00000000000..5ea6d86df8b --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/StallingHttpServer.java @@ -0,0 +1,94 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.util.concurrent.TimeUnit.SECONDS; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.net.URI; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; + +/** A raw HTTP endpoint that stalls before headers or after the first response body byte. */ +final class StallingHttpServer implements AutoCloseable { + private final ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); + private final CompletableFuture received = new CompletableFuture<>(); + private final CompletableFuture disconnected = new CompletableFuture<>(); + private final CountDownLatch headersAllowed; + private final Thread thread; + private final URI uri; + private volatile Socket connection; + + StallingHttpServer(final boolean sendHeaders) throws Exception { + this.uri = + new URI( + "http", + null, + this.server.getInetAddress().getHostAddress(), + this.server.getLocalPort(), + "/", + null, + null); + this.headersAllowed = new CountDownLatch(sendHeaders ? 0 : 1); + this.thread = new Thread(this::serve, "stalling-http-server"); + this.thread.setDaemon(true); + this.thread.start(); + } + + URI uri() { + return this.uri; + } + + void awaitRequest() throws Exception { + this.received.get(5, SECONDS); + } + + void sendHeaders() { + this.headersAllowed.countDown(); + } + + boolean awaitDisconnect() throws Exception { + return this.disconnected.get(5, SECONDS); + } + + private void serve() { + try (Socket socket = this.server.accept()) { + this.connection = socket; + socket.setSoTimeout(10_000); + final BufferedReader reader = + new BufferedReader(new InputStreamReader(socket.getInputStream(), US_ASCII)); + String line; + while ((line = reader.readLine()) != null && !line.isEmpty()) {} + this.received.complete(null); + if (!this.headersAllowed.await(5, SECONDS)) { + throw new IllegalStateException("Response headers were not released"); + } + socket + .getOutputStream() + .write("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\nx".getBytes(US_ASCII)); + socket.getOutputStream().flush(); + this.disconnected.complete(reader.read() == -1); + } catch (final SocketException e) { + this.received.completeExceptionally(e); + this.disconnected.complete(true); + } catch (final Exception e) { + this.received.completeExceptionally(e); + this.disconnected.completeExceptionally(e); + } + } + + @Override + public void close() throws Exception { + this.server.close(); + final Socket socket = this.connection; + if (socket != null) { + socket.close(); + } + this.thread.interrupt(); + this.thread.join(SECONDS.toMillis(5)); + } +} From c01c5b466d7596698a054e2cbf5609a6e1c5e829 Mon Sep 17 00:00:00 2001 From: Sarah Chen Date: Wed, 14 Oct 2026 20:17:44 -0400 Subject: [PATCH 40/50] fix(ffe): Reject null events before reserving queue capacity --- .../datadog/openfeature/internal/EventQueue.java | 3 +++ .../openfeature/internal/EventQueueTest.java | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java index 2bea9fb33e9..d2de2f8f239 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/EventQueue.java @@ -30,6 +30,9 @@ public EventQueue(final int capacity) { * @return {@code true} if the element was queued, {@code false} if the queue is full. */ public boolean offer(final E element) { + if (element == null) { + throw new NullPointerException("element"); + } if (this.size.incrementAndGet() > this.capacity) { this.size.decrementAndGet(); return false; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java index a030740cefc..fbee9cc5944 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/EventQueueTest.java @@ -17,6 +17,20 @@ import org.junit.jupiter.api.Test; class EventQueueTest { + @Test + void rejectsNullWithoutConsumingCapacity() { + final EventQueue queue = new EventQueue<>(1); + + assertThrows(NullPointerException.class, () -> queue.offer(null)); + assertEquals(0, queue.size()); + assertTrue(queue.offer("a")); + + assertThrows(NullPointerException.class, () -> queue.offer(null)); + assertEquals(1, queue.size()); + assertEquals("a", queue.poll()); + assertEquals(0, queue.size()); + } + @Test void rejectsOffersBeyondCapacity() { final EventQueue queue = new EventQueue<>(2); From fab4025b83e9e386fdaf7712f6abac3fde3045ac Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 07:38:20 +0200 Subject: [PATCH 41/50] feat(ffe): Add a setting to stop sending exposures to Datadog Read feature.flags.exposures.datadog.logging.enabled, enabled by default, as introduced by #12774 for the previous provider. --- .../openfeature/internal/config/Settings.java | 12 ++++++++++++ .../internal/config/SettingsTest.java | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index 7dbab1cbd2f..8fb75928cf4 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -33,6 +33,8 @@ public final class Settings { static final String SPAN_ENRICHMENT_ENABLED = "experimental.flagging.provider.span.enrichment.enabled"; static final String EVALUATION_COUNTS_ENABLED = "flagging.evaluation.counts.enabled"; + static final String EXPOSURES_DATADOG_LOGGING_ENABLED = + "feature.flags.exposures.datadog.logging.enabled"; static final String API_KEY = "api-key"; static final String SITE = "site"; static final String ENV = "env"; @@ -149,6 +151,16 @@ public boolean isEvaluationCountsEnabled() { return !Boolean.FALSE.equals(getBoolean(EVALUATION_COUNTS_ENABLED)); } + /** + * Tells whether exposures are sent to Datadog. When off, exposures are still deduplicated so + * exposure hooks get the same decisions, and flag evaluation events are still sent. + * + * @return whether exposures are sent to Datadog (on by default). + */ + public boolean isExposuresDatadogLoggingEnabled() { + return !Boolean.FALSE.equals(getBoolean(EXPOSURES_DATADOG_LOGGING_ENABLED)); + } + /** * @return the Datadog API key, or {@code null} if not set. */ diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java index 2cab13382c7..170cbd2a8e8 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -87,6 +87,23 @@ void appliesDefaults() { assertEquals(5, settings.agentlessRequestTimeoutSeconds()); assertFalse(settings.isSpanEnrichmentEnabled()); assertTrue(settings.isEvaluationCountsEnabled()); + assertTrue(settings.isExposuresDatadogLoggingEnabled()); + } + + @TableTest({ + "scenario | value | enabled", + "true | 'true' | true ", + "one | '1' | true ", + "false | 'false' | false ", + "zero | '0' | false ", + "invalid | 'nope' | false ", + "blank | ' ' | true " + }) + void readsTheExposuresDatadogLoggingToggle(final String value, final boolean enabled) { + assertEquals( + enabled, + TestSettings.of("feature.flags.exposures.datadog.logging.enabled", value) + .isExposuresDatadogLoggingEnabled()); } @TableTest({ From 271f673c77ad9ee680f44f370889a46b0c9ea2bc Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 07:38:22 +0200 Subject: [PATCH 42/50] feat(ffe): Send split features with exposures and flag evaluations Port the event side of #12774: exposures carry their exposure features, and flag evaluations carry their evaluation features, which split the aggregation buckets. --- .../internal/exposure/ExposureEvent.java | 17 +++ .../internal/exposure/ExposuresRequest.java | 4 + .../flagevaluation/FlagEvalEvent.java | 29 +++++ .../FlagEvaluationAggregator.java | 96 ++++++++++++---- .../FlagEvaluationPayloads.java | 17 ++- .../exposure/ExposuresRequestTest.java | 20 +++- .../flagevaluation/FlagEvalEventTest.java | 24 ++++ .../FlagEvaluationAggregatorTest.java | 105 +++++++++++++++++- .../FlagEvaluationPayloadsTest.java | 56 +++++++++- 9 files changed, 334 insertions(+), 34 deletions(-) create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEventTest.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java index a49811908ba..303ef19f783 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureEvent.java @@ -1,5 +1,7 @@ package com.datadog.openfeature.internal.exposure; +import java.util.Map; + public class ExposureEvent { // milliseconds since epoch as given by System.currentTimeMillis() public final long timestamp; @@ -8,6 +10,9 @@ public class ExposureEvent { public final Variant variant; public final Subject subject; public final Integer serial_id; + // Split features routed to the EXPOSURE destination. Null when there are none, so the field is + // omitted from the exposure payload. + public final Map features; public ExposureEvent( final long timestamp, @@ -16,11 +21,23 @@ public ExposureEvent( final Variant variant, final Subject subject, final Integer serialId) { + this(timestamp, allocation, flag, variant, subject, serialId, null); + } + + public ExposureEvent( + final long timestamp, + final Allocation allocation, + final Flag flag, + final Variant variant, + final Subject subject, + final Integer serialId, + final Map features) { this.timestamp = timestamp; this.allocation = allocation; this.flag = flag; this.variant = variant; this.subject = subject; this.serial_id = serialId; + this.features = features; } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java index c1522dc210e..cbeefd12070 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposuresRequest.java @@ -44,6 +44,10 @@ public byte[] serialize() { if (exposure.serial_id != null) { generator.writeNumberField("serial_id", exposure.serial_id); } + if (exposure.features != null && !exposure.features.isEmpty()) { + generator.writeFieldName("features"); + writeValue(generator, exposure.features); + } generator.writeEndObject(); } generator.writeEndArray(); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java index f31ea9a3da1..b5331265d41 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEvent.java @@ -54,6 +54,12 @@ public final class FlagEvalEvent { */ public final boolean observeFullEvaluationData; + /** + * Split features routed to the EVALUATION destination. They come from the flag configuration, not + * the evaluation context, so they are kept whatever the consent. May be empty but never null. + */ + public final Map features; + /** Convenience constructor; consent defaults to the privacy-preserving false. */ public FlagEvalEvent( final String flagKey, @@ -86,6 +92,28 @@ public FlagEvalEvent( final long evalTimeMs, final boolean observeFullEvaluationData, final Map attrs) { + this( + flagKey, + variant, + allocationKey, + targetingKey, + errorMessage, + evalTimeMs, + observeFullEvaluationData, + attrs, + null); + } + + public FlagEvalEvent( + final String flagKey, + final String variant, + final String allocationKey, + final String targetingKey, + final String errorMessage, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map attrs, + final Map features) { this.flagKey = flagKey; this.variant = variant; this.allocationKey = allocationKey; @@ -94,5 +122,6 @@ public FlagEvalEvent( this.evalTimeMs = evalTimeMs; this.observeFullEvaluationData = observeFullEvaluationData; this.attrs = attrs != null ? attrs : Collections.emptyMap(); + this.features = features != null ? features : Collections.emptyMap(); } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java index ed35250b7b9..5b9ea55cea4 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregator.java @@ -1,5 +1,6 @@ package com.datadog.openfeature.internal.flagevaluation; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -55,6 +56,9 @@ private static int addToHash(int hash, boolean value) { final Map perFlagCount = new HashMap<>(); final AtomicLong droppedDegradedOverflow = new AtomicLong(0); final AtomicInteger globalFullCount = new AtomicInteger(0); + // One shared key and map for each distinct feature set in the flush window, so buckets of many + // subjects do not each keep their own copy until the flush. + final Map featureSets = new HashMap<>(); void aggregate(final FlagEvalEvent event) { final boolean isDefault = event.variant == null; @@ -64,7 +68,11 @@ void aggregate(final FlagEvalEvent event) { // and spills every subsequent evaluation into the degraded tier. final Map prunedAttrs = observeFullEvaluationData ? event.attrs : null; final String ctxKey = observeFullEvaluationData ? canonicalContextKey(prunedAttrs) : ""; - final FullKey fullKey = buildFullKey(event, ctxKey); + // Features come from the flag configuration, not the evaluation context, so they split buckets + // in both tiers and whatever the consent: rows with different features must not merge. + final FeatureSet featureSet = featureSet(event.features); + final String featuresKey = featureSet.key; + final FullKey fullKey = buildFullKey(event, ctxKey, featuresKey); EvalBucket bucket = fullTier.get(fullKey); if (bucket != null) { @@ -86,13 +94,14 @@ void aggregate(final FlagEvalEvent event) { event.evalTimeMs, isDefault, prunedAttrs, - observeFullEvaluationData)); + observeFullEvaluationData, + featureSet.features)); globalFullCount.incrementAndGet(); perFlagCount.put(event.flagKey, flagCount + 1); return; } - final DegradedKey degradedKey = buildDegradedKey(event); + final DegradedKey degradedKey = buildDegradedKey(event, featuresKey); bucket = degradedTier.get(degradedKey); if (bucket != null) { bucket.merge(event.evalTimeMs, isDefault); @@ -112,7 +121,8 @@ void aggregate(final FlagEvalEvent event) { event.evalTimeMs, isDefault, null, - observeFullEvaluationData)); + observeFullEvaluationData, + featureSet.features)); return; } @@ -150,10 +160,36 @@ Iterable degradedBuckets() { void clear() { fullTier.clear(); degradedTier.clear(); + this.featureSets.clear(); perFlagCount.clear(); globalFullCount.set(0); } + private FeatureSet featureSet(final Map features) { + if (features == null || features.isEmpty()) { + return FeatureSet.NONE; + } + final String key = canonicalContextKey(features); + FeatureSet featureSet = this.featureSets.get(key); + if (featureSet == null) { + featureSet = new FeatureSet(key, features); + this.featureSets.put(key, featureSet); + } + return featureSet; + } + + static final class FeatureSet { + static final FeatureSet NONE = new FeatureSet("", Collections.emptyMap()); + + final String key; + final Map features; + + FeatureSet(final String key, final Map features) { + this.key = key; + this.features = features; + } + } + AggregatedState snapshot() { return new AggregatedState( new HashMap<>(fullTier), new HashMap<>(degradedTier), droppedDegradedOverflow.get()); @@ -163,8 +199,8 @@ void simulateFullTierAtCap() { for (int i = globalFullCount.get(); i < GLOBAL_CAP; i++) { final String key = "synthetic-full-" + i; fullTier.put( - new FullKey(key, "on", "alloc", false, null, null, "", false), - new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + new FullKey(key, "on", "alloc", false, null, null, "", false, ""), + new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false, null)); globalFullCount.incrementAndGet(); perFlagCount.merge(key, 1, Integer::sum); } @@ -174,8 +210,8 @@ void simulateDegradedTierAtCap() { for (int i = degradedTier.size(); i < DEGRADED_CAP; i++) { final String key = "synthetic-dg-" + i; degradedTier.put( - new DegradedKey(key, "on", "alloc", false, null), - new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false)); + new DegradedKey(key, "on", "alloc", false, null, ""), + new EvalBucket(key, "on", "alloc", null, null, 1L, false, null, false, null)); } } @@ -186,7 +222,7 @@ void addDegradedBucketForTest( final String errorMessage, final long evalTimeMs) { degradedTier.put( - new DegradedKey(flagKey, variant, allocationKey, variant == null, errorMessage), + new DegradedKey(flagKey, variant, allocationKey, variant == null, errorMessage, ""), new EvalBucket( flagKey, variant, @@ -196,10 +232,12 @@ void addDegradedBucketForTest( evalTimeMs, variant == null, null, - false)); + false, + null)); } - private static FullKey buildFullKey(final FlagEvalEvent event, final String ctxKey) { + private static FullKey buildFullKey( + final FlagEvalEvent event, final String ctxKey, final String featuresKey) { return new FullKey( event.flagKey, event.variant, @@ -208,16 +246,18 @@ private static FullKey buildFullKey(final FlagEvalEvent event, final String ctxK event.errorMessage, event.targetingKey, ctxKey, - event.observeFullEvaluationData); + event.observeFullEvaluationData, + featuresKey); } - private static DegradedKey buildDegradedKey(final FlagEvalEvent event) { + private static DegradedKey buildDegradedKey(final FlagEvalEvent event, final String featuresKey) { return new DegradedKey( event.flagKey, event.variant, event.allocationKey, event.variant == null, - event.errorMessage); + event.errorMessage, + featuresKey); } static String canonicalContextKey(final Map prunedAttrs) { @@ -280,6 +320,8 @@ static class EvalBucket { String targetingKey; String errorMessage; Map prunedAttrs; + // Identical for every evaluation in the bucket, because the features key is a bucket dimension. + Map features; // Consent to emit raw PII. For full-tier buckets this is uniform (consent is a FullKey // dimension) and the AND-fold on merge is defensive. For degraded-tier buckets consent is NOT // a key dimension — mixed-consent events merge here — so the AND-fold produces false whenever @@ -297,7 +339,8 @@ static class EvalBucket { final long evalTimeMs, final boolean runtimeDefaultUsed, final Map prunedAttrs, - final boolean observeFullEvaluationData) { + final boolean observeFullEvaluationData, + final Map features) { this.flagKey = flagKey; this.variant = variant; this.allocationKey = allocationKey; @@ -309,6 +352,7 @@ static class EvalBucket { this.runtimeDefaultUsed = runtimeDefaultUsed; this.prunedAttrs = prunedAttrs; this.observeFullEvaluationData = observeFullEvaluationData; + this.features = features; } int prunedContextFieldCount() { @@ -341,6 +385,7 @@ static final class FullKey { // serializer branches on this to hash the targeting key and drop the context, so events with // different consent produce different wire rows and belong in different buckets. private final boolean observeFullEvaluationData; + private final String featuresKey; FullKey( final String flagKey, @@ -350,7 +395,8 @@ static final class FullKey { final String errorMessage, final String targetingKey, final String contextKey, - final boolean observeFullEvaluationData) { + final boolean observeFullEvaluationData, + final String featuresKey) { this.flagKey = flagKey; this.variant = variant; this.allocationKey = allocationKey; @@ -359,6 +405,7 @@ static final class FullKey { this.targetingKey = targetingKey; this.contextKey = contextKey; this.observeFullEvaluationData = observeFullEvaluationData; + this.featuresKey = featuresKey; } @Override @@ -377,7 +424,8 @@ public boolean equals(final Object o) { && Objects.equals(allocationKey, fullKey.allocationKey) && Objects.equals(errorMessage, fullKey.errorMessage) && Objects.equals(targetingKey, fullKey.targetingKey) - && Objects.equals(contextKey, fullKey.contextKey); + && Objects.equals(contextKey, fullKey.contextKey) + && Objects.equals(featuresKey, fullKey.featuresKey); } @Override @@ -389,7 +437,8 @@ public int hashCode() { result = addToHash(result, errorMessage); result = addToHash(result, targetingKey); result = addToHash(result, contextKey); - return addToHash(result, observeFullEvaluationData); + result = addToHash(result, observeFullEvaluationData); + return addToHash(result, featuresKey); } } @@ -406,18 +455,21 @@ static final class DegradedKey { private final String allocationKey; private final boolean runtimeDefaultUsed; private final String errorMessage; + private final String featuresKey; DegradedKey( final String flagKey, final String variant, final String allocationKey, final boolean runtimeDefaultUsed, - final String errorMessage) { + final String errorMessage, + final String featuresKey) { this.flagKey = flagKey; this.variant = variant; this.allocationKey = allocationKey; this.runtimeDefaultUsed = runtimeDefaultUsed; this.errorMessage = errorMessage; + this.featuresKey = featuresKey; } @Override @@ -433,7 +485,8 @@ public boolean equals(final Object o) { && Objects.equals(flagKey, that.flagKey) && Objects.equals(variant, that.variant) && Objects.equals(allocationKey, that.allocationKey) - && Objects.equals(errorMessage, that.errorMessage); + && Objects.equals(errorMessage, that.errorMessage) + && Objects.equals(featuresKey, that.featuresKey); } @Override @@ -442,7 +495,8 @@ public int hashCode() { // on this hot bucket-lookup path. int result = hash(flagKey, variant, allocationKey); result = addToHash(result, runtimeDefaultUsed); - return addToHash(result, errorMessage); + result = addToHash(result, errorMessage); + return addToHash(result, featuresKey); } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java index 90ced6c3ac4..0918083534c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloads.java @@ -119,6 +119,10 @@ static byte[] encodeEvent(final FlagEvaluationEvent event) { generator.writeStringField("message", event.error.message); generator.writeEndObject(); } + if (event.features != null) { + generator.writeFieldName("features"); + writeValue(generator, event.features); + } generator.writeEndObject(); }); } @@ -213,6 +217,7 @@ static class FlagEvaluationEvent { public final Boolean runtime_default_used; public final EventContext context; public final ErrorObject error; + public final Map features; FlagEvaluationEvent( final long timestamp, @@ -225,7 +230,8 @@ static class FlagEvaluationEvent { final String targetingKey, final boolean runtimeDefaultUsed, final String errorMessage, - final Map evaluationAttrs) { + final Map evaluationAttrs, + final Map features) { this.timestamp = timestamp; this.flag = new FlagKeyObject(flagKey); this.first_evaluation = firstEvalMs; @@ -242,6 +248,7 @@ static class FlagEvaluationEvent { : null; this.error = (errorMessage != null && !errorMessage.isEmpty()) ? new ErrorObject(errorMessage) : null; + this.features = (features != null && !features.isEmpty()) ? features : null; } static FlagEvaluationEvent fromBucket( @@ -261,7 +268,10 @@ static FlagEvaluationEvent fromBucket( resolveTargetingKey(bucket.targetingKey, isFullTier, observeFullEvaluationData), bucket.runtimeDefaultUsed, bucket.errorMessage, - includeRawContext ? bucket.prunedAttrs : null); + includeRawContext ? bucket.prunedAttrs : null, + // Features come from the flag configuration, so both tiers keep them whatever the + // consent. + bucket.features); } private static String resolveTargetingKey( @@ -292,7 +302,8 @@ FlagEvaluationEvent withoutTargetingKeyAndContext() { null, Boolean.TRUE.equals(runtime_default_used), messageOf(error), - null); + null, + features); } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java index c4a61ba23a7..f0325cdf60e 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposuresRequestTest.java @@ -28,6 +28,19 @@ void omitsSerialIdKeyWhenAbsent() { assertFalse(exposureJson(null).contains("serial_id")); } + @Test + void serializesExposureFeaturesAfterTheSerialId() { + final String json = exposureJson(7, singletonMap("bandit.policy_id", "p2")); + + assertTrue(json.contains("\"serial_id\":7,\"features\":{\"bandit.policy_id\":\"p2\"}")); + } + + @Test + void omitsFeaturesKeyWhenAbsentOrEmpty() { + assertFalse(exposureJson(7).contains("features")); + assertFalse(exposureJson(7, emptyMap()).contains("features")); + } + @Test void serializesContextAndSubject() { final ExposureEvent event = @@ -49,6 +62,10 @@ void serializesContextAndSubject() { } private static String exposureJson(final Integer serialId) { + return exposureJson(serialId, null); + } + + private static String exposureJson(final Integer serialId, final Map features) { final ExposureEvent event = new ExposureEvent( 1234L, @@ -56,7 +73,8 @@ private static String exposureJson(final Integer serialId) { new Flag("flag"), new Variant("variant"), new Subject("subject", emptyMap()), - serialId); + serialId, + features); return new String(new ExposuresRequest(emptyMap(), singletonList(event)).serialize(), UTF_8); } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEventTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEventTest.java new file mode 100644 index 00000000000..2dc60610258 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvalEventTest.java @@ -0,0 +1,24 @@ +package com.datadog.openfeature.internal.flagevaluation; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class FlagEvalEventTest { + @Test + void storesFeaturesAndDefaultsThemToEmpty() { + final Map features = singletonMap("bandit.policy_id", "p2"); + + assertSame( + features, + new FlagEvalEvent("f", "on", "a", "t", null, 1L, false, emptyMap(), features).features); + assertTrue( + new FlagEvalEvent("f", "on", "a", "t", null, 1L, false, emptyMap(), null) + .features.isEmpty()); + assertTrue(new FlagEvalEvent("f", "on", "a", "t", 1L, emptyMap()).features.isEmpty()); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java index 811cd0eaa08..11b3d65b7d3 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationAggregatorTest.java @@ -1,13 +1,17 @@ package com.datadog.openfeature.internal.flagevaluation; import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonMap; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -217,7 +221,7 @@ void flagEvalEventDoesNotCarryReason() { void evalBucketTracksBoundsDefaultStateAndNullContextFieldCount() { final FlagEvaluationAggregator.EvalBucket bucket = new FlagEvaluationAggregator.EvalBucket( - "bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false); + "bucket-flag", "on", "alloc1", "user-1", null, 1000L, false, null, false, null); assertEquals(0, bucket.prunedContextFieldCount()); @@ -409,7 +413,8 @@ private static FlagEvaluationAggregator.FullKey fullKey( errorMessage, targetingKey, contextKey, - false); + false, + ""); } private static FlagEvaluationAggregator.DegradedKey degradedKey( @@ -419,6 +424,100 @@ private static FlagEvaluationAggregator.DegradedKey degradedKey( final boolean runtimeDefaultUsed, final String errorMessage) { return new FlagEvaluationAggregator.DegradedKey( - flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage); + flagKey, variant, allocationKey, runtimeDefaultUsed, errorMessage, ""); + } + + @Test + void featuresSplitBucketsInBothTiersWhateverTheConsent() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + final Map p1 = singletonMap("bandit.policy_id", "p1"); + final Map p2 = singletonMap("bandit.policy_id", "p2"); + + aggregator.aggregate(featureEvent("user-1", 1000L, false, p1)); + aggregator.aggregate(featureEvent("user-1", 2000L, false, p2)); + aggregator.aggregate(featureEvent("user-1", 3000L, false, p1)); + + final FlagEvaluationAggregator.AggregatedState full = aggregator.snapshot(); + assertEquals(2, full.fullTier.size()); + for (final FlagEvaluationAggregator.EvalBucket bucket : full.fullTier.values()) { + assertEquals(p1.equals(bucket.features) ? 2 : 1, bucket.count); + } + + aggregator.simulateFullTierAtCap(); + aggregator.aggregate(featureEvent("user-2", 4000L, false, p1)); + aggregator.aggregate(featureEvent("user-3", 5000L, false, p2)); + aggregator.aggregate(featureEvent("user-4", 6000L, false, p1)); + + final FlagEvaluationAggregator.AggregatedState degraded = aggregator.snapshot(); + assertEquals(2, degraded.degradedTier.size()); + for (final FlagEvaluationAggregator.EvalBucket bucket : degraded.degradedTier.values()) { + assertEquals(p1.equals(bucket.features) ? 2 : 1, bucket.count); + } + } + + @Test + void bucketsShareOneCopyOfEachFeatureSetUntilTheWindowIsCleared() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(featureEvent("user-1", 1000L, false, policy("p1"))); + aggregator.aggregate(featureEvent("user-2", 2000L, false, policy("p1"))); + aggregator.aggregate(featureEvent("user-3", 3000L, false, policy("p2"))); + + final List p1Buckets = new ArrayList<>(); + for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.fullTier.values()) { + if (policy("p1").equals(bucket.features)) { + p1Buckets.add(bucket); + } + } + assertEquals(2, p1Buckets.size()); + assertSame(p1Buckets.get(0).features, p1Buckets.get(1).features); + assertEquals(2, aggregator.featureSets.size()); + + aggregator.simulateFullTierAtCap(); + aggregator.aggregate(featureEvent("user-4", 4000L, false, policy("p1"))); + for (final FlagEvaluationAggregator.EvalBucket bucket : aggregator.degradedTier.values()) { + assertSame(p1Buckets.get(0).features, bucket.features); + } + + aggregator.clear(); + assertTrue(aggregator.featureSets.isEmpty()); + } + + @Test + void bucketsWithoutFeaturesShareTheEmptyFeatureSet() { + final FlagEvaluationAggregator aggregator = new FlagEvaluationAggregator(); + + aggregator.aggregate(featureEvent("user-1", 1000L, false, null)); + aggregator.aggregate(featureEvent("user-1", 2000L, false, emptyMap())); + + final FlagEvaluationAggregator.AggregatedState state = aggregator.snapshot(); + assertEquals(1, state.fullTier.size()); + assertSame( + FlagEvaluationAggregator.FeatureSet.NONE.features, + state.fullTier.values().iterator().next().features); + assertTrue(aggregator.featureSets.isEmpty()); + } + + private static Map policy(final String id) { + final Map features = new HashMap<>(); + features.put("bandit.policy_id", id); + return features; + } + + private static FlagEvalEvent featureEvent( + final String targetingKey, + final long evalTimeMs, + final boolean observeFullEvaluationData, + final Map features) { + return new FlagEvalEvent( + "feature-flag", + "on", + "alloc1", + targetingKey, + null, + evalTimeMs, + observeFullEvaluationData, + emptyMap(), + features); } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java index 389e2b3c30f..592fea69d40 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPayloadsTest.java @@ -48,7 +48,7 @@ void fullTierPayloadUsesWorkerWireShape() throws Exception { void eventFromFullBucketUsesFlushTimeAndEvaluationBounds() throws Exception { final FlagEvaluationAggregator.EvalBucket bucket = new FlagEvaluationAggregator.EvalBucket( - "ts-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, emptyMap(), true); + "ts-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, emptyMap(), true, null); bucket.merge(EVAL_MS + 10, false); final long flushTimeMs = EVAL_MS + 5_000; @@ -72,7 +72,7 @@ void eventFromFullBucketUsesFlushTimeAndEvaluationBounds() throws Exception { void degradedTierEventOmitsTargetingKeyAndContext() throws Exception { final FlagEvaluationAggregator.EvalBucket bucket = new FlagEvaluationAggregator.EvalBucket( - "dg-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false); + "dg-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false, null); final Map json = firstPayload( @@ -102,7 +102,8 @@ void fullTierWithObserveFullEvaluationDataTrueEmitsRawTargetingKeyAndContext() t EVAL_MS, false, attrs, - true); + true, + null); final Map json = firstPayload( @@ -137,7 +138,8 @@ void fullTierWithObserveFullEvaluationDataFalseHashesTargetingKeyAndOmitsContext EVAL_MS, false, attrs, - false); + false, + null); final FlagEvaluationPayloads.EncodedPayloads payloads = FlagEvaluationPayloads.buildPayloads( @@ -298,6 +300,7 @@ void errorPayloadSerializesErrorObject() throws Exception { null, true, "type mismatch", + null, null)), CONTEXT, 1_000_000)); @@ -333,6 +336,7 @@ void degradingEventWithMissingOptionalFieldsKeepsOptionalObjectsAbsent() { "user-1", true, null, + null, null) .withoutTargetingKeyAndContext(); @@ -351,7 +355,7 @@ void emptyOptionalStringsAreTreatedAsAbsentWhenContextIsPresent() { attrs.put("tier", "gold"); final FlagEvaluationPayloads.FlagEvaluationEvent event = new FlagEvaluationPayloads.FlagEvaluationEvent( - EVAL_MS, "empty-optionals", EVAL_MS, EVAL_MS, 1, "", "", null, false, "", attrs); + EVAL_MS, "empty-optionals", EVAL_MS, EVAL_MS, 1, "", "", null, false, "", attrs, null); assertNull(event.variant); assertNull(event.allocation); @@ -383,7 +387,8 @@ private static FlagEvaluationPayloads.FlagEvaluationEvent event( targetingKey, false, null, - attrs); + attrs, + null); } private static Map firstPayload( @@ -427,4 +432,43 @@ private static Map context() { context.put("service", "test-service"); return context; } + + @Test + void rowsCarryFeaturesInEveryTierAndWithoutConsent() throws Exception { + final Map features = new HashMap<>(); + features.put("bandit.policy_id", "p2"); + features.put("bandit.weight", 0.5); + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "feature-flag", "on", "alloc1", "user-1", null, EVAL_MS, false, null, false, features); + + final FlagEvaluationPayloads.FlagEvaluationEvent full = + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, true, false, EVAL_MS); + final FlagEvaluationPayloads.FlagEvaluationEvent degraded = + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, false, false, EVAL_MS); + + assertEquals(features, firstEvent(onlyPayload(full)).get("features")); + assertEquals(features, firstEvent(onlyPayload(degraded)).get("features")); + assertEquals(features, full.withoutTargetingKeyAndContext().features); + } + + @Test + void rowsWithoutFeaturesOmitTheField() throws Exception { + final FlagEvaluationAggregator.EvalBucket bucket = + new FlagEvaluationAggregator.EvalBucket( + "plain-flag", "on", "alloc1", null, null, EVAL_MS, false, null, false, emptyMap()); + + final FlagEvaluationPayloads.FlagEvaluationEvent row = + FlagEvaluationPayloads.FlagEvaluationEvent.fromBucket(bucket, false, false, EVAL_MS); + + assertNull(row.features); + assertFalse(firstEvent(onlyPayload(row)).containsKey("features")); + } + + private static Map onlyPayload( + final FlagEvaluationPayloads.FlagEvaluationEvent row) throws Exception { + return firstPayload( + FlagEvaluationPayloads.buildPayloads( + java.util.Collections.singletonList(row), CONTEXT, 1_000_000)); + } } From 2bf0025db34aa478282ad75b94a4d86dab22e3f4 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 07:38:22 +0200 Subject: [PATCH 43/50] feat(ffe): Declare the exposures Datadog logging setting for the agent Add the agent configuration key and supported configuration entry for feature.flags.exposures.datadog.logging.enabled, and run the smoke test application with the OpenFeature SDK 1.23.0 the provider requires. --- dd-smoke-tests/openfeature/application/build.gradle | 2 +- .../datadog/trace/api/config/FeatureFlaggingConfig.java | 7 +++++++ metadata/supported-configurations.json | 8 ++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/dd-smoke-tests/openfeature/application/build.gradle b/dd-smoke-tests/openfeature/application/build.gradle index 1b7860d0c57..130fc74d876 100644 --- a/dd-smoke-tests/openfeature/application/build.gradle +++ b/dd-smoke-tests/openfeature/application/build.gradle @@ -33,6 +33,6 @@ if (hasProperty('featureFlaggingApiJar')) { dependencies { // OpenFeature SDK is an API dependency of dd-openfeature but is not // transitively resolved when the jar is passed as a files() dependency. - implementation 'dev.openfeature:sdk:1.22.0' + implementation 'dev.openfeature:sdk:1.23.0' implementation 'org.springframework.boot:spring-boot-starter-web' } 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 index 9a232111344..b3937f18761 100644 --- 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 @@ -13,5 +13,12 @@ public final class FeatureFlaggingConfig { public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE = "feature.flags.configuration.source"; + /** + * Turns off sending exposures to Datadog while customer exposure hooks keep receiving them. + * Default: enabled. Maps to {@code DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED}. + */ + public static final String FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED = + "feature.flags.exposures.datadog.logging.enabled"; + private FeatureFlaggingConfig() {} } diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index cafadc1d6b8..00d9bfa5364 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -1604,6 +1604,14 @@ "aliases": [] } ], + "DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "true", + "aliases": [] + } + ], "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": [ { "version": "A", From 810145b19adffb0cdc1d4e803a53eec6205f47c7 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 07:38:23 +0200 Subject: [PATCH 44/50] feat(ffe): Send exposures after evaluation and add ExposureHook Port #12774 to the dd-openfeature provider. Exposures are sent from a finally-stage hook once the evaluation succeeded, deduplicated by a provider-side cache that replaces the exposure pipeline cache, and applications can record them with ExposureHook. Split features are added to the flag metadata. Require OpenFeature SDK 1.23.0 so provider hooks run inside a MultiProvider. --- .../dd-openfeature-connector/gradle.lockfile | 5 +- dd-openfeature/README.md | 70 ++- dd-openfeature/build.gradle.kts | 2 +- dd-openfeature/gradle.lockfile | 5 +- .../FlagEvalHookHotPathBenchmark.java | 4 +- .../com/datadog/openfeature/DDEvaluator.java | 93 ++-- .../com/datadog/openfeature/ExposureHook.java | 164 +++++++ .../openfeature/ExposureLoggingHook.java | 99 ++++ .../openfeature/FlagEvalLoggingHook.java | 31 +- .../com/datadog/openfeature/Provider.java | 16 +- .../openfeature/SpanEnrichmentHook.java | 19 +- .../openfeature/SplitFeatureMetadata.java | 38 ++ .../internal/FeatureFlagsRuntime.java | 37 +- .../internal/evaluation/FlagEvaluator.java | 181 +++++-- .../internal/evaluation/MetadataKeys.java | 51 ++ .../internal/exposure/ExposureCache.java | 64 --- .../exposure/ExposureDeduplicationCache.java | 143 ++++++ .../internal/exposure/ExposurePipeline.java | 26 +- .../internal/exposure/LRUExposureCache.java | 41 -- .../DDEvaluatorSpanEnrichmentTest.java | 10 +- .../datadog/openfeature/DDEvaluatorTest.java | 81 ++-- .../DatadogExposureLoggingTest.java | 154 ++++++ .../datadog/openfeature/ExposureHookTest.java | 440 ++++++++++++++++++ .../openfeature/ExposureLoggingHookTest.java | 307 ++++++++++++ .../openfeature/ExposureTestEvaluator.java | 56 +++ .../openfeature/FlagEvalLoggingHookTest.java | 99 +++- .../com/datadog/openfeature/ProviderTest.java | 21 +- .../internal/FeatureFlagsRuntimeTest.java | 23 + .../evaluation/FlagEvaluatorTest.java | 241 ++++++++++ .../ExposureDeduplicationCacheTest.java | 146 ++++++ .../exposure/ExposurePipelineTest.java | 22 +- .../exposure/LRUExposureCacheTest.java | 308 ------------ 32 files changed, 2377 insertions(+), 620 deletions(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/SplitFeatureMetadata.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/MetadataKeys.java delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureDeduplicationCache.java delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/DatadogExposureLoggingTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/ExposureHookTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/ExposureTestEvaluator.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/FlagEvaluatorTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposureDeduplicationCacheTest.java delete mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile index 5de2fc3c490..40c9c574e4b 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile @@ -52,7 +52,7 @@ commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClassp commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs de.thetaphi:forbiddenapis:3.11=compileClasspath,testCompileClasspath,testRuntimeClasspath -dev.openfeature:sdk:1.22.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath +dev.openfeature:sdk:1.23.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath io.opentelemetry:opentelemetry-api:1.66.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-common:1.66.0=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath @@ -119,7 +119,8 @@ 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.30=buildTimeInstrumentationPlugin,compileClasspath,main_java11RuntimeClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j -org.slf4j:slf4j-api:2.0.18=main_java11CompileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=spotbugs +org.slf4j:slf4j-api:2.0.19=main_java11CompileClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j org.snakeyaml:snakeyaml-engine:2.9=buildTimeInstrumentationPlugin,main_java11RuntimeClasspath,muzzleTooling,runtimeClasspath,testRuntimeClasspath org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md index 26f604ba2e0..6f649411f76 100644 --- a/dd-openfeature/README.md +++ b/dd-openfeature/README.md @@ -13,7 +13,7 @@ The SDK works on its own. When the Datadog Java agent (`dd-java-agent`) is attac ## Setup -Add the dependency. It brings the OpenFeature SDK, the OpenTelemetry API and `jackson-core`: +Add the dependency. It brings the OpenFeature SDK 1.23.0, the OpenTelemetry API and `jackson-core`: ```xml @@ -35,6 +35,8 @@ Client client = OpenFeatureAPI.getInstance().getClient(); `new Provider(new Provider.Options().initTimeout(10, SECONDS))` changes how long initialization waits for the first flag configuration (30 seconds by default). +The provider sends exposures from an OpenFeature provider hook. If you wrap it in OpenFeature's `MultiProvider`, use `dev.openfeature:sdk` 1.23.0 or later: earlier versions do not run the hooks of the providers inside a `MultiProvider`, so the provider sends no exposures there. + ## Configuration Settings are read from the Datadog Java agent configuration when attached, then from `dd.`-prefixed system properties, then from environment variables. @@ -47,6 +49,7 @@ Settings are read from the Datadog Java agent configuration when attached, then | `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS` | `30` | CDN poll interval. | | `DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS` | `5` | CDN request timeout. | | `DD_FLAGGING_EVALUATION_COUNTS_ENABLED` | `true` | Sends aggregated flag evaluation counts. | +| `DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED` | `true` | Sends exposures to Datadog. See [Stop sending exposures to Datadog](#stop-sending-exposures-to-datadog). | | `DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED` | `false` | Adds flag evaluations to the local root span. Requires `dd-java-agent`. | | `DD_API_KEY`, `DD_SITE`, `DD_ENV`, `DD_SERVICE`, `DD_VERSION` | | Credentials and event context. | | `DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED` | | Deprecated: `true` selects `remote_config`, `false` disables Feature Flags. An explicit source wins. | @@ -62,6 +65,71 @@ Settings are read from the Datadog Java agent configuration when attached, then | Health metrics | No | Agent telemetry | | Span enrichment | No | Yes | +## Exposures in your own data store + +The provider sends exposures to Datadog. It also puts its exposure decision and the selected split's features into the flag metadata of every evaluation, so any OpenFeature hook can record exposures in your own data store (see [Flag metadata](#flag-metadata)). + +For convenience, `dd-openfeature` ships `ExposureHook`, which reads that metadata for you. It receives every completed evaluation, with the same exposure decision that the provider uses for Datadog. + +```java +import com.datadog.openfeature.ExposureHook; + +BlockingQueue queue = new LinkedBlockingQueue<>(10_000); + +OpenFeatureAPI.getInstance().addHooks(new ExposureHook(evaluation -> { + if (!evaluation.shouldSend()) { + return; + } + Map features = evaluation.getFeatures(); + queue.offer(new ExposureRecord( + evaluation.getDetails().getFlagKey(), + evaluation.getDetails().getVariant(), + evaluation.getContext().getTargetingKey(), + (String) features.get("holdout.key"), + (String) features.get("holdout.assignment_group"))); +})); +// A background thread drains the queue and writes to the data store. +``` + +* The callback runs on the evaluation thread. Do not write to the data store from the callback. Copy what you need and hand it to a background queue. +* `shouldSend()` is true for a successful evaluation of an allocation that logs exposures, for a subject not yet exposed to the same allocation, variant and serial id. These are exactly the exposures that the provider sends to Datadog, as long as Datadog logging is on and the provider's own hooks run: evaluate through an OpenFeature client, and inside a `MultiProvider` use OpenFeature SDK 1.23.0 or later. With an older SDK, a provider inside a `MultiProvider` sends no exposures, and `shouldSend()` is true for every exposure. +* `isExposure()` is true for every successful evaluation of an allocation that logs exposures, including repeats. `isCacheHit()` tells repeats apart. +* An evaluation that fails, including one that a later `after` hook fails, is not an exposure. +* An exception from the callback does not affect the evaluation. The first one is logged at warn level, later ones at debug level. + +### Features + +`getFeatures()` returns the features of the selected split whose destinations include `HOOK`. Values are a `String`, `Double` or `Boolean`. Holdout splits carry: + +| Feature | Value | +|---|---| +| `holdout.key` | The holdout key | +| `holdout.assignment_group` | `status_quo` or `all_shipped` | + +The map is empty when the split has no such features. Datadog can add destinations later; the provider ignores a destination it does not know and logs a warning once for each one. + +### Flag metadata + +The same data is in the evaluation's flag metadata, for hooks that use only the OpenFeature API. Read it in the hook's `finallyAfter` stage, where the details match what the application receives, and skip details that have an error code. `ExposureHook` does both. + +| Key | Type | Meaning | +|---|---|---| +| `allocationKey` | String | The selected allocation | +| `__dd_do_log` | Boolean | Whether the allocation logs exposures. Present when the allocation logs exposures or span enrichment is on | +| `__dd_exposure_cache_hit` | Boolean | The subject was already exposed. Present only when `__dd_do_log` is true | +| `__dd_split_serial_id` | Integer | The split's serial id. Present when `__dd_do_log` is present and the split has a serial id | +| `__dd_feature.` | String, Double or Boolean | One entry for each feature of the selected split that lists the `HOOK` destination | + +### Stop sending exposures to Datadog + +```shell +DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED=false +``` + +The provider then sends no exposures to Datadog. `ExposureHook` callbacks still receive every evaluation, and `shouldSend()` gives the same result as before. The default is `true`. + +This setting stops exposures only. Flag evaluation events have their own setting, `DD_FLAGGING_EVALUATION_COUNTS_ENABLED`. The Java provider does not read dd-trace-py's `DD_FFE_INTAKE_ENABLED`. + ## Migrating from earlier versions * The provider moved to `com.datadog.openfeature.Provider`. `datadog.trace.api.openfeature.Provider` is kept as a deprecated alias. diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts index a0a715a005b..5f712f5928c 100644 --- a/dd-openfeature/build.gradle.kts +++ b/dd-openfeature/build.gradle.kts @@ -40,7 +40,7 @@ java { } dependencies { - api("dev.openfeature:sdk:1.22.0") + api("dev.openfeature:sdk:1.23.0") api("io.opentelemetry:opentelemetry-api:1.66.0") api(libs.jackson.core) diff --git a/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile index 55a26499a91..cee0a0fa791 100644 --- a/dd-openfeature/gradle.lockfile +++ b/dd-openfeature/gradle.lockfile @@ -16,7 +16,7 @@ com.google.errorprone:error_prone_annotations:2.48.0=spotbugs com.thoughtworks.qdox:qdox:1.12.1=codenarc commons-io:commons-io:2.21.0=spotbugs de.thetaphi:forbiddenapis:3.11=compileClasspath -dev.openfeature:sdk:1.22.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +dev.openfeature:sdk:1.23.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath io.opentelemetry:opentelemetry-api:1.66.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-common:1.66.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -71,7 +71,8 @@ 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:2.0.17=spotbugsSlf4j -org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=spotbugs +org.slf4j:slf4j-api:2.0.19=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath 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 diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java index 34412974120..0d0a554a892 100644 --- a/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/FlagEvalHookHotPathBenchmark.java @@ -1,5 +1,6 @@ package com.datadog.openfeature; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.OBSERVE_FULL_EVALUATION_DATA; import static java.util.concurrent.TimeUnit.NANOSECONDS; import static java.util.concurrent.TimeUnit.SECONDS; @@ -122,8 +123,7 @@ private static FlagEvaluationDetails details(final boolean observeFullEv ImmutableMetadata.builder() .addString("allocationKey", "alloc-1") .addLong("__dd_eval_timestamp_ms", 1_700_000_000_000L) - .addBoolean( - DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .addBoolean(OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) .build()) .build(); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index f1ae8d1b40a..04b8b840c55 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -7,13 +7,13 @@ import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.evaluation.ConfigurationStore; import com.datadog.openfeature.internal.evaluation.FlagEvaluator; +import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.exposure.Subject; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.ValueType; import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Structure; @@ -82,15 +82,6 @@ class DDEvaluator implements Evaluator, Consumer { */ static final int MAX_STRUCTURE_PROPERTIES = 256; - // 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"; - - // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing - // key = non-DD provider; the hook falls back to false (fail-closed). - static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; - private final Runnable configCallback; private final Connector connector; private final Settings settings; @@ -102,9 +93,9 @@ class DDEvaluator implements Evaluator, Consumer { this.configCallback = configCallback; this.connector = connector; this.settings = settings; - // When span enrichment is off, the __dd_* span-enrichment metadata is not attached to - // evaluations, so an enabled provider pays nothing extra. - this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::dispatchExposure); + // When span enrichment is off, the __dd_* span-enrichment metadata is attached only to + // evaluations of allocations logging exposures, for the exposure hook. + this.core = new FlagEvaluator(settings.isSpanEnrichmentEnabled(), this::isExposed); } @Override @@ -173,25 +164,62 @@ static T mapValue(final Class target, final Object value) { return FlagEvaluator.mapValue(target, value); } - private void dispatchExposure( + private boolean isExposed( final String flag, - final ProviderEvaluation evaluation, + final String subject, + final String allocation, + final String variant, + final Integer serialId) { + final ExposureDeduplicationCache cache = exposureCache(); + return cache != null && cache.contains(flag, subject, allocation, variant, serialId); + } + + /** + * Gets the exposure deduplication cache of the runtime. + * + * @return the cache, or {@code null} if the evaluator is not initialized. + */ + ExposureDeduplicationCache exposureCache() { + final FeatureFlagsRuntime current = this.runtime; + return current == null ? null : current.exposureCache(); + } + + /** + * Tells whether exposures are sent to Datadog. + * + * @return whether the evaluator is initialized with exposure delivery enabled. + */ + boolean isExposureDeliveryEnabled() { + final FeatureFlagsRuntime current = this.runtime; + return current != null && current.isExposureDeliveryEnabled(); + } + + /** + * Builds the exposure of an evaluation. + * + * @param flag the flag key. + * @param allocationKey the allocation key. + * @param variantKey the variant key. + * @param context the evaluation context. + * @param serialId the split serial id, or {@code null} if absent. + * @param features the split features sent with the exposure, possibly empty. + * @return the exposure event. + */ + static ExposureEvent exposureEvent( + final String flag, + final String allocationKey, + final String variantKey, + final EvaluationContext context, final Integer serialId, - final EvaluationContext context) { - final String allocationKey = allocationKey(evaluation); - final String variantKey = evaluation.getVariant(); - if (allocationKey == null || variantKey == null) { - return; - } - final ExposureEvent event = - new ExposureEvent( - System.currentTimeMillis(), - new com.datadog.openfeature.internal.exposure.Allocation(allocationKey), - new com.datadog.openfeature.internal.exposure.Flag(flag), - new com.datadog.openfeature.internal.exposure.Variant(variantKey), - new Subject(context.getTargetingKey(), flattenContext(context)), - serialId); - recordExposure(event); + final Map features) { + return new ExposureEvent( + System.currentTimeMillis(), + new com.datadog.openfeature.internal.exposure.Allocation(allocationKey), + new com.datadog.openfeature.internal.exposure.Flag(flag), + new com.datadog.openfeature.internal.exposure.Variant(variantKey), + new Subject(context.getTargetingKey(), flattenContext(context)), + serialId, + features.isEmpty() ? null : features); } /** @@ -206,11 +234,6 @@ void recordExposure(final ExposureEvent event) { } } - private static String allocationKey(final ProviderEvaluation resolution) { - final ImmutableMetadata meta = resolution.getFlagMetadata(); - return meta == null ? null : meta.getString("allocationKey"); - } - static AbstractMap flattenContext(final EvaluationContext context) { return flattenValues(snapshotValues(context)); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java new file mode 100644 index 00000000000..b8cce08551a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureHook.java @@ -0,0 +1,164 @@ +package com.datadog.openfeature; + +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.ALLOCATION_KEY; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_CACHE_HIT; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.FEATURE_PREFIX; +import static java.util.Collections.emptyMap; +import static java.util.Objects.requireNonNull; + +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 java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An OpenFeature hook that hands each completed flag evaluation to a callback, with the Datadog + * exposure decision, so an application can record exposures in its own data store. + * + *

    This hook is optional. The provider puts the same data in the flag metadata, so any + * OpenFeature hook can read it (see "Flag metadata" in the README). This class reads it for you. + * + *

    Register it with {@code OpenFeatureAPI.getInstance().addHooks(new ExposureHook(callback))} or + * on a client. The callback runs for every evaluation, including errors and repeats. Use {@link + * Evaluation#shouldSend()} to keep the new or changed exposures. + * + *

    When the provider's own hooks run and {@code + * DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED} is on, these are exactly the exposures that + * Datadog sends. With that setting off, Datadog receives no exposures, and {@code shouldSend()} + * gives the same results. The provider's hooks run when the application evaluates through an + * OpenFeature client. Inside a {@code MultiProvider} they run only with OpenFeature SDK 1.23.0 or + * later. With an older SDK, Datadog receives no exposures from a provider inside a {@code + * MultiProvider}, and {@code shouldSend()} is true for every exposure. + * + *

    The callback runs on the evaluation thread: hand the data to a background queue instead of + * writing it from the callback. An exception from the callback does not affect the evaluation. The + * first one is logged at warn level, later ones at debug level. + */ +public final class ExposureHook implements Hook { + + private static final Logger log = LoggerFactory.getLogger(ExposureHook.class); + + private final Consumer callback; + private final AtomicBoolean warned = new AtomicBoolean(); + + /** + * Creates an exposure hook. + * + * @param callback the callback receiving each completed evaluation. + * @throws NullPointerException if the callback is {@code null}. + */ + public ExposureHook(final Consumer callback) { + this.callback = requireNonNull(callback, "callback"); + } + + @Override + public void finallyAfter( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + if (details == null) { + return; + } + try { + this.callback.accept(new Evaluation(context.getCtx(), details)); + } catch (final LinkageError | RuntimeException e) { + if (this.warned.compareAndSet(false, true)) { + log.warn("Exposure callback failed; later failures are logged at debug level", e); + } else { + log.debug("Exposure callback failed", e); + } + } + } + + /** A completed evaluation and its Datadog exposure decision. */ + public static final class Evaluation { + private final EvaluationContext context; + private final FlagEvaluationDetails details; + + Evaluation(final EvaluationContext context, final FlagEvaluationDetails details) { + this.context = context; + this.details = details; + } + + /** + * Gets the evaluation context. + * + * @return the evaluation context, including the targeting key. + */ + public EvaluationContext getContext() { + return this.context; + } + + /** + * Gets the evaluation details. + * + * @return the flag key, value, variant, error and flag metadata of the evaluation. + */ + public FlagEvaluationDetails getDetails() { + return this.details; + } + + /** + * Tells whether the evaluation is an exposure, whether or not this subject was already exposed. + * + * @return {@code true} when the evaluation succeeded and selected an allocation that logs + * exposures. + */ + public boolean isExposure() { + final Map values = metadata(); + return this.details.getErrorCode() == null + && this.details.getVariant() != null + && values.get(ALLOCATION_KEY) instanceof String + && Boolean.TRUE.equals(values.get(DO_LOG)); + } + + /** + * Tells whether this exposure repeats one already recorded for this subject: same allocation, + * variant and serial id. + * + * @return {@code true} for a repeated exposure, {@code false} for a new or changed exposure and + * for an evaluation that is not an exposure. + */ + public boolean isCacheHit() { + return isExposure() && Boolean.TRUE.equals(cacheHitStamp()); + } + + /** + * Tells whether this is a new or changed exposure: the decision the provider uses for Datadog. + * The class Javadoc says when that decision reaches Datadog. + * + * @return {@code true} for a new or changed exposure. + */ + public boolean shouldSend() { + return isExposure() && Boolean.FALSE.equals(cacheHitStamp()); + } + + /** + * Gets the features of the selected split, such as {@code holdout.key} and {@code + * holdout.assignment_group}. Values are a String, Double or Boolean. + * + * @return the unmodifiable features, empty when the split has none. + */ + public Map getFeatures() { + return SplitFeatureMetadata.featuresWithPrefix( + this.details.getFlagMetadata(), FEATURE_PREFIX); + } + + private Object cacheHitStamp() { + return metadata().get(EXPOSURE_CACHE_HIT); + } + + // The ImmutableMetadata getters build a debug message for every absent key. + private Map metadata() { + final ImmutableMetadata metadata = this.details.getFlagMetadata(); + return metadata == null ? emptyMap() : metadata.asUnmodifiableMap(); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java new file mode 100644 index 00000000000..f0065abc6df --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -0,0 +1,99 @@ +package com.datadog.openfeature; + +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.ALLOCATION_KEY; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_CACHE_HIT; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; + +import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; +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 java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Records the exposure of an allocation logging exposures once the evaluation has succeeded, and + * sends it to Datadog when it is new or changed. + * + *

    It runs at the finally stage because OpenFeature runs provider hooks first: an after hook + * added by the application could still throw and turn the result into the default value. At the + * finally stage the details carry that error, so a failed evaluation sends no exposure and does not + * mark the subject as exposed. + */ +class ExposureLoggingHook implements Hook { + + private static final Logger log = LoggerFactory.getLogger(ExposureLoggingHook.class); + + /** Evaluator resolver, returning {@code null} when the provider has no Datadog evaluator. */ + private final Supplier evaluatorSupplier; + + private final AtomicBoolean warned = new AtomicBoolean(); + + ExposureLoggingHook(final Supplier evaluatorSupplier) { + this.evaluatorSupplier = evaluatorSupplier; + } + + @Override + public void finallyAfter( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + try { + if (details == null || details.getErrorCode() != null) { + return; + } + final ImmutableMetadata metadata = details.getFlagMetadata(); + if (metadata == null) { + return; + } + // The ImmutableMetadata getters build a debug message for every absent key. + final Map values = metadata.asUnmodifiableMap(); + if (!Boolean.TRUE.equals(values.get(DO_LOG)) + || !Boolean.FALSE.equals(values.get(EXPOSURE_CACHE_HIT))) { + return; + } + final Object allocationValue = values.get(ALLOCATION_KEY); + final String allocationKey = + allocationValue instanceof String ? (String) allocationValue : null; + final String variantKey = details.getVariant(); + final EvaluationContext context = ctx == null ? null : ctx.getCtx(); + if (allocationKey == null || variantKey == null || context == null) { + return; + } + final DDEvaluator evaluator = this.evaluatorSupplier.get(); + final ExposureDeduplicationCache cache = evaluator == null ? null : evaluator.exposureCache(); + if (cache == null) { + return; + } + final String flag = details.getFlagKey(); + final Object serialValue = values.get(SPLIT_SERIAL_ID); + final Integer serialId = serialValue instanceof Integer ? (Integer) serialValue : null; + // The exposure is recorded even when it is not sent, so exposure hooks get the same + // decisions whether or not exposures go to Datadog. + if (cache.recordIfChanged( + flag, context.getTargetingKey(), allocationKey, variantKey, serialId) + && evaluator.isExposureDeliveryEnabled()) { + evaluator.recordExposure( + DDEvaluator.exposureEvent( + flag, + allocationKey, + variantKey, + context, + serialId, + SplitFeatureMetadata.featuresWithPrefix(metadata, EXPOSURE_FEATURE_PREFIX))); + } + } catch (final RuntimeException | LinkageError e) { + // Never let exposure reporting break flag evaluation. + if (this.warned.compareAndSet(false, true)) { + log.warn("Could not record a feature flag exposure; later failures are not logged", e); + } + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java index 5baab2df44a..504ce4a3337 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/FlagEvalLoggingHook.java @@ -1,5 +1,11 @@ package com.datadog.openfeature; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.ALLOCATION_KEY; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EVALUATION_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EVAL_TIMESTAMP_MS; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.HAS_EVALUATION_FEATURES; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.OBSERVE_FULL_EVALUATION_DATA; + import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.FlagEvaluationDetails; @@ -73,11 +79,16 @@ public void finallyAfter( final ImmutableMetadata metadata = details.getFlagMetadata(); // allocationKey: "allocationKey" (camelCase) - consistent with FlagEvalMetricsHook.java - final String allocationKey = metadata != null ? metadata.getString("allocationKey") : null; + // The ImmutableMetadata getters build a debug message for every absent key. + final Map values = + metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); + final Object allocationValue = values.get(ALLOCATION_KEY); + final String allocationKey = + allocationValue instanceof String ? (String) allocationValue : null; // eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time. - // ImmutableMetadata.getLong available since sdk 1.4+. - final Long evalTimeObj = metadata != null ? metadata.getLong("__dd_eval_timestamp_ms") : null; + final Object evalTimeValue = values.get(EVAL_TIMESTAMP_MS); + final Long evalTimeObj = evalTimeValue instanceof Long ? (Long) evalTimeValue : null; final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis(); // variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the @@ -90,10 +101,9 @@ public void finallyAfter( // Consent is read from metadata stamped by DDEvaluator (pinned to its ServerConfiguration). // Missing key = non-DD provider → false, the privacy-preserving default. + final Object consentValue = values.get(OBSERVE_FULL_EVALUATION_DATA); final Boolean consentFromMetadata = - metadata != null - ? metadata.getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA) - : null; + consentValue instanceof Boolean ? (Boolean) consentValue : null; final boolean observeFullEvaluationData = consentFromMetadata != null && consentFromMetadata; // Error message: prefer the human-readable message under consent-on; under consent-off the @@ -126,6 +136,12 @@ public void finallyAfter( attrs = Collections.emptyMap(); } + // Features come from the flag configuration, not the evaluation context, so they are sent + // whatever the consent. + final Map features = + Boolean.TRUE.equals(values.get(HAS_EVALUATION_FEATURES)) + ? SplitFeatureMetadata.featuresWithPrefix(metadata, EVALUATION_FEATURE_PREFIX) + : Collections.emptyMap(); w.enqueue( new FlagEvalEvent( flagKey, @@ -135,7 +151,8 @@ public void finallyAfter( errorMessage, evalTimeMs, observeFullEvaluationData, - attrs)); + attrs, + features)); } catch (LinkageError e) { // Never let EVP recording break flag evaluation } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java index 62f1ab751e5..3d672ef1e3a 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -101,7 +101,7 @@ public Provider(final Options options) { // 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<>(3); + final List hooks = new ArrayList<>(4); if (flagEvalMetricsHook != null) { hooks.add(flagEvalMetricsHook); } @@ -111,6 +111,9 @@ public Provider(final Options options) { if (spanEnrichmentHook != null) { hooks.add(spanEnrichmentHook); } + // Exposure hook: always registered last; no-op when the evaluator is not a Datadog one or is + // not initialized. + hooks.add(new ExposureLoggingHook<>(this::datadogEvaluator)); this.providerHooks = hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); @@ -228,14 +231,19 @@ private Evaluator buildEvaluator() { } private FlagEvaluationWriter flagEvaluationPipeline() { - final Evaluator current = this.evaluator; - if (!(current instanceof DDEvaluator)) { + final DDEvaluator current = datadogEvaluator(); + if (current == null) { return null; } - final FeatureFlagsRuntime runtime = ((DDEvaluator) current).runtime(); + final FeatureFlagsRuntime runtime = current.runtime(); return runtime == null ? null : runtime.evaluations(); } + private DDEvaluator datadogEvaluator() { + final Evaluator current = this.evaluator; + return current instanceof DDEvaluator ? (DDEvaluator) current : null; + } + @Override public List getProviderHooks() { return providerHooks; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java index 525ba9badf9..0d4ec8af05f 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java @@ -1,6 +1,7 @@ package com.datadog.openfeature; import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.evaluation.MetadataKeys; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.Hook; @@ -10,6 +11,7 @@ import dev.openfeature.sdk.Value; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -38,9 +40,9 @@ 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; + // The metadata keys the evaluator attaches for span enrichment. + static final String METADATA_SERIAL_ID = MetadataKeys.SPLIT_SERIAL_ID; + static final String METADATA_DO_LOG = MetadataKeys.DO_LOG; private final SpanEnricher enricher; @@ -58,10 +60,13 @@ public void finallyAfter( } 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)); - this.enricher.serialId(serialId, doLog, targetingKey(ctx)); + // The ImmutableMetadata getters build a debug message for every absent key. + final Map values = + metadata == null ? Collections.emptyMap() : metadata.asUnmodifiableMap(); + final Object serialValue = values.get(METADATA_SERIAL_ID); + if (serialValue instanceof Integer) { + final boolean doLog = Boolean.TRUE.equals(values.get(METADATA_DO_LOG)); + this.enricher.serialId((Integer) serialValue, 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 connector carries only JDK types. diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/SplitFeatureMetadata.java b/dd-openfeature/src/main/java/com/datadog/openfeature/SplitFeatureMetadata.java new file mode 100644 index 00000000000..d1941237605 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/SplitFeatureMetadata.java @@ -0,0 +1,38 @@ +package com.datadog.openfeature; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableMap; + +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.HashMap; +import java.util.Map; + +/** Reads the split features that the evaluator copies into the flag metadata. */ +final class SplitFeatureMetadata { + + private SplitFeatureMetadata() {} + + /** + * Reads the features stored in the metadata under a prefix. + * + * @param metadata the flag metadata, or {@code null} if absent. + * @param prefix the destination prefix of the features. + * @return the unmodifiable features keyed without the prefix, empty if none. + */ + static Map featuresWithPrefix( + final ImmutableMetadata metadata, final String prefix) { + if (metadata == null) { + return emptyMap(); + } + Map features = null; + for (final Map.Entry entry : metadata.asUnmodifiableMap().entrySet()) { + if (entry.getKey().startsWith(prefix)) { + if (features == null) { + features = new HashMap<>(); + } + features.put(entry.getKey().substring(prefix.length()), entry.getValue()); + } + } + return features == null ? emptyMap() : unmodifiableMap(features); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java index b787eb0488c..fedcc1a41d0 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -10,6 +10,7 @@ import com.datadog.openfeature.internal.delivery.DirectIntakeTransport; import com.datadog.openfeature.internal.delivery.FallbackTransport; import com.datadog.openfeature.internal.delivery.RemoteConfigurationService; +import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.exposure.ExposurePipeline; import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationPipeline; @@ -36,6 +37,7 @@ public final class FeatureFlagsRuntime { private static int references; private final ConfigurationService source; + private final ExposureDeduplicationCache exposureCache = new ExposureDeduplicationCache(); @Nullable private final ExposurePipeline exposures; @Nullable private final FlagEvaluationPipeline evaluations; private final List> listeners = new CopyOnWriteArrayList<>(); @@ -118,13 +120,15 @@ static FeatureFlagsRuntime create(final Connector connector, final Settings sett } final Map context = eventContext(settings); final ExposurePipeline exposures = - new ExposurePipeline( - ExposurePipeline.DEFAULT_CAPACITY, - ExposurePipeline.DEFAULT_FLUSH_INTERVAL_IN_SECONDS, - TimeUnit.SECONDS, - transport, - context, - services); + settings.isExposuresDatadogLoggingEnabled() + ? new ExposurePipeline( + ExposurePipeline.DEFAULT_CAPACITY, + ExposurePipeline.DEFAULT_FLUSH_INTERVAL_IN_SECONDS, + TimeUnit.SECONDS, + transport, + context, + services) + : null; final FlagEvaluationPipeline evaluations = settings.isEvaluationCountsEnabled() ? new FlagEvaluationPipeline( @@ -211,6 +215,25 @@ public void removeConfigurationListener(final Consumer list this.listeners.remove(listener); } + /** + * Gets the cache deduplicating the exposures of all the providers sharing this runtime. It is + * kept even when exposures are not sent, so exposure hooks get the same decisions. + * + * @return the exposure deduplication cache. + */ + public ExposureDeduplicationCache exposureCache() { + return this.exposureCache; + } + + /** + * Tells whether exposures are sent to Datadog. + * + * @return whether exposure delivery is enabled. + */ + public boolean isExposureDeliveryEnabled() { + return this.exposures != null; + } + /** * Records an exposure, if exposure delivery is enabled. * diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java index 96fa1f90829..dd4b4956492 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -1,10 +1,21 @@ package com.datadog.openfeature.internal.evaluation; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.ALLOCATION_KEY; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EVALUATION_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EVAL_TIMESTAMP_MS; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_CACHE_HIT; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.HAS_EVALUATION_FEATURES; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.OBSERVE_FULL_EVALUATION_DATA; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; import static java.util.Arrays.asList; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ConditionConfiguration; import com.datadog.openfeature.internal.ufc.ConditionOperator; +import com.datadog.openfeature.internal.ufc.Feature; import com.datadog.openfeature.internal.ufc.Flag; import com.datadog.openfeature.internal.ufc.ParsedSemver; import com.datadog.openfeature.internal.ufc.Rule; @@ -28,24 +39,36 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** Local evaluation over a single configuration snapshot. No transport dependency. */ public final class FlagEvaluator { + private static final Logger LOGGER = LoggerFactory.getLogger(FlagEvaluator.class); private static final Set> SUPPORTED_RESOLUTION_TYPES = new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); - private static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = - "observe_full_evaluation_data"; - private static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; - private static final String METADATA_DO_LOG = "__dd_do_log"; + static final String FEATURE_DESTINATION_HOOK = "HOOK"; + static final String FEATURE_DESTINATION_EXPOSURE = "EXPOSURE"; + static final String FEATURE_DESTINATION_EVALUATION = "EVALUATION"; + // A newer UFC can name destinations this SDK does not know; warn once per name, not per + // evaluation. + static final int MAX_WARNED_FEATURE_DESTINATIONS = 64; + static final Set WARNED_FEATURE_DESTINATIONS = ConcurrentHashMap.newKeySet(); private final boolean spanEnrichmentEnabled; - private final ExposureListener exposureListener; + private final ExposureLookup exposureLookup; - public FlagEvaluator( - final boolean spanEnrichmentEnabled, final ExposureListener exposureListener) { + /** + * Creates an evaluator. + * + * @param spanEnrichmentEnabled whether span enrichment is enabled. + * @param exposureLookup the lookup telling whether an exposure was already recorded. + */ + public FlagEvaluator(final boolean spanEnrichmentEnabled, final ExposureLookup exposureLookup) { this.spanEnrichmentEnabled = spanEnrichmentEnabled; - this.exposureListener = exposureListener; + this.exposureLookup = exposureLookup; } public ProviderEvaluation evaluate( @@ -118,7 +141,6 @@ public ProviderEvaluation evaluate( if (isEmpty(split.shards)) { return resolveVariant( target, - key, defaultValue, flag, split.variationKey, @@ -143,7 +165,6 @@ public ProviderEvaluation evaluate( if (allShardsMatch) { return resolveVariant( target, - key, defaultValue, flag, split.variationKey, @@ -175,7 +196,7 @@ public ProviderEvaluation evaluate( private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { return ImmutableMetadata.builder() - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .addBoolean(OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) .build(); } @@ -392,7 +413,6 @@ private static String getMD5Hash(final String input) { private ProviderEvaluation resolveVariant( final Class target, - final String key, final T defaultValue, final Flag flag, final String variationKey, @@ -443,36 +463,98 @@ private ProviderEvaluation resolveVariant( ImmutableMetadata.builder() .addString("flagKey", flag.key) .addString("variationType", flag.variationType.name()) - .addString("allocationKey", allocation.key) - .addLong("__dd_eval_timestamp_ms", evalTimestampMs) - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); - // 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 (spanEnrichmentEnabled) { + .addString(ALLOCATION_KEY, allocation.key) + .addLong(EVAL_TIMESTAMP_MS, evalTimestampMs) + .addBoolean(OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); + // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment and + // for the exposure hook, which sends the exposure after the evaluation succeeds. An evaluation + // that neither logs exposures nor enriches spans carries neither key. + // __dd_split_serial_id is omitted when the split carries no serial id. + final boolean doLog = Boolean.TRUE.equals(allocation.doLog); + if (spanEnrichmentEnabled || doLog) { if (split.serialId != null) { - metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); + metadataBuilder.addInteger(SPLIT_SERIAL_ID, split.serialId); } - metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); - } - final ProviderEvaluation result = - ProviderEvaluation.builder() - .value(mappedValue) - .reason( - !isEmpty(allocation.rules) - ? Reason.TARGETING_MATCH.name() - : allocation.startAt != null || allocation.endAt != null - ? Reason.DEFAULT.name() - : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) - .variant(variant.key) - .flagMetadata(metadataBuilder.build()) - .build(); - if (Boolean.TRUE.equals(allocation.doLog)) { - exposureListener.onExposure(key, result, split.serialId, context); + metadataBuilder.addBoolean(DO_LOG, doLog); + } + addSplitFeatures(metadataBuilder, split); + if (doLog) { + metadataBuilder.addBoolean( + EXPOSURE_CACHE_HIT, + this.exposureLookup.isExposed( + flag.key, context.getTargetingKey(), allocation.key, variant.key, split.serialId)); + } + return ProviderEvaluation.builder() + .value(mappedValue) + .reason( + !isEmpty(allocation.rules) + ? Reason.TARGETING_MATCH.name() + : allocation.startAt != null || allocation.endAt != null + ? Reason.DEFAULT.name() + : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) + .variant(variant.key) + .flagMetadata(metadataBuilder.build()) + .build(); + } + + private static void addSplitFeatures( + final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, final Split split) { + if (split.features == null) { + return; + } + Set seenKeys = null; + boolean evaluationFeatures = false; + for (final Feature feature : split.features) { + if (feature == null || feature.key == null || feature.destinations == null) { + continue; + } + if (seenKeys == null) { + seenKeys = new HashSet<>(); + } + // The first feature with a key wins, for every destination. + if (!seenKeys.add(feature.key)) { + continue; + } + for (final String destination : feature.destinations) { + final String prefix = metadataPrefix(destination); + if (prefix != null) { + addFeature(metadataBuilder, prefix + feature.key, feature.value); + evaluationFeatures |= EVALUATION_FEATURE_PREFIX.equals(prefix); + } else if (WARNED_FEATURE_DESTINATIONS.size() < MAX_WARNED_FEATURE_DESTINATIONS + && WARNED_FEATURE_DESTINATIONS.add(destination)) { + LOGGER.warn("Ignoring unknown feature destination {}", destination); + } + } + } + if (evaluationFeatures) { + metadataBuilder.addBoolean(HAS_EVALUATION_FEATURES, true); + } + } + + private static String metadataPrefix(final String destination) { + if (FEATURE_DESTINATION_HOOK.equals(destination)) { + return FEATURE_PREFIX; + } + if (FEATURE_DESTINATION_EXPOSURE.equals(destination)) { + return EXPOSURE_FEATURE_PREFIX; + } + if (FEATURE_DESTINATION_EVALUATION.equals(destination)) { + return EVALUATION_FEATURE_PREFIX; + } + return null; + } + + private static void addFeature( + final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, + final String key, + final Object value) { + if (value instanceof String) { + metadataBuilder.addString(key, (String) value); + } else if (value instanceof Boolean) { + metadataBuilder.addBoolean(key, (Boolean) value); + } else if (value instanceof Number) { + metadataBuilder.addDouble(key, ((Number) value).doubleValue()); } - return result; } private static Object resolveAttribute(final String name, final EvaluationContext context) { @@ -541,22 +623,21 @@ private static Double parseDouble(final Object value) { return Double.parseDouble(String.valueOf(value)); } - /** Receives the evaluations whose allocation logs exposures. */ + /** Tells whether an exposure was already recorded, so it is not sent again. */ @FunctionalInterface - public interface ExposureListener { + public interface ExposureLookup { /** - * Called when an evaluation resolves to an allocation logging exposures. + * Tells whether this exposure is the last one recorded for the flag and subject. * - * @param flagKey the evaluated flag key. - * @param evaluation the evaluation result. + * @param flag the flag key. + * @param subject the subject, the targeting key. + * @param allocation the allocation key. + * @param variant the variant key. * @param serialId the split serial id, or {@code null} if absent. - * @param context the evaluation context. + * @return whether the exposure was already recorded. */ - void onExposure( - String flagKey, - ProviderEvaluation evaluation, - Integer serialId, - EvaluationContext context); + boolean isExposed( + String flag, String subject, String allocation, String variant, Integer serialId); } @FunctionalInterface diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/MetadataKeys.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/MetadataKeys.java new file mode 100644 index 00000000000..d7a19ac79cf --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/MetadataKeys.java @@ -0,0 +1,51 @@ +package com.datadog.openfeature.internal.evaluation; + +/** The flag metadata keys the evaluator writes for the provider hooks and exposure hooks. */ +public final class MetadataKeys { + /** The key of the selected allocation. */ + public static final String ALLOCATION_KEY = "allocationKey"; + + /** The evaluation timestamp, in milliseconds since epoch. */ + public static final String EVAL_TIMESTAMP_MS = "__dd_eval_timestamp_ms"; + + /** + * The consent to send the full evaluation data, stamped on every evaluation. A missing key means + * a non-Datadog provider. + */ + public static final String OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; + + /** + * The serial id of the selected split, emitted with {@link #DO_LOG} and omitted when the split + * has none. + */ + public static final String SPLIT_SERIAL_ID = "__dd_split_serial_id"; + + /** + * Whether the selected allocation logs exposures, emitted when span enrichment is on or the + * allocation logs exposures. + */ + public static final String DO_LOG = "__dd_do_log"; + + /** + * Emitted only when the allocation logs exposures: true when this subject's exposure was already + * recorded, so it is not sent again. + */ + public static final String EXPOSURE_CACHE_HIT = "__dd_exposure_cache_hit"; + + /** The prefix of the split features delivered to hooks. This is a public contract. */ + public static final String FEATURE_PREFIX = "__dd_feature."; + + /** The prefix of the split features sent with exposures. */ + public static final String EXPOSURE_FEATURE_PREFIX = "__dd_exposure_feature."; + + /** The prefix of the split features sent with flag evaluations. */ + public static final String EVALUATION_FEATURE_PREFIX = "__dd_evaluation_feature."; + + /** + * Present only when the split routes a feature to flag evaluations, so the flag evaluation hook + * can skip scanning the metadata on every other evaluation. + */ + public static final String HAS_EVALUATION_FEATURES = "__dd_has_evaluation_features"; + + private MetadataKeys() {} +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java deleted file mode 100644 index 0ce6e6c31d9..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureCache.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.datadog.openfeature.internal.exposure; - -import java.util.Objects; - -public interface ExposureCache { - - boolean add(ExposureEvent event); - - Value get(Key key); - - int size(); - - final class Key { - public final String flag; - public final String subject; - - public Key(final ExposureEvent event) { - this.flag = event.flag == null ? null : event.flag.key; - this.subject = event.subject == null ? null : event.subject.id; - } - - @Override - public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) { - return false; - } - final Key key = (Key) o; - return Objects.equals(flag, key.flag) && Objects.equals(subject, key.subject); - } - - @Override - public int hashCode() { - return Objects.hash(flag, subject); - } - } - - final class Value { - public final String variant; - public final String allocation; - public final Integer serialId; - - public Value(final ExposureEvent event) { - this.variant = event.variant == null ? null : event.variant.key; - this.allocation = event.allocation == null ? null : event.allocation.key; - this.serialId = event.serial_id; - } - - @Override - public boolean equals(final Object o) { - if (o == null || getClass() != o.getClass()) { - return false; - } - final Value value = (Value) o; - return Objects.equals(variant, value.variant) - && Objects.equals(allocation, value.allocation) - && Objects.equals(serialId, value.serialId); - } - - @Override - public int hashCode() { - return Objects.hash(variant, allocation, serialId); - } - } -} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureDeduplicationCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureDeduplicationCache.java new file mode 100644 index 00000000000..cc7f2ad19df --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposureDeduplicationCache.java @@ -0,0 +1,143 @@ +package com.datadog.openfeature.internal.exposure; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Remembers the last exposure recorded for each (flag, subject). An exposure is sent again only + * when its allocation, variant or serial id changes. Thread-safe. + */ +public final class ExposureDeduplicationCache { + static final int CAPACITY = 1 << 16; + + private final Map entries; + + /** Creates a cache remembering up to 65536 subjects, evicting the least recently used ones. */ + public ExposureDeduplicationCache() { + this(CAPACITY); + } + + ExposureDeduplicationCache(final int capacity) { + this.entries = new BoundedLru(capacity); + } + + /** + * Tells whether an exposure was already recorded for this subject. + * + * @param flag the flag key. + * @param subject the subject, the targeting key. + * @param allocation the allocation key. + * @param variant the variant key. + * @param serialId the split serial id, or {@code null} if absent. + * @return whether the last exposure recorded for this flag and subject is the same one. + */ + public synchronized boolean contains( + final String flag, + final String subject, + final String allocation, + final String variant, + final Integer serialId) { + return new Sent(allocation, variant, serialId).equals(this.entries.get(new Key(flag, subject))); + } + + /** + * Records an exposure as the last one of this subject. + * + * @param flag the flag key. + * @param subject the subject, the targeting key. + * @param allocation the allocation key. + * @param variant the variant key. + * @param serialId the split serial id, or {@code null} if absent. + * @return whether the exposure is new or changed for this flag and subject. + */ + public synchronized boolean recordIfChanged( + final String flag, + final String subject, + final String allocation, + final String variant, + final Integer serialId) { + final Sent sent = new Sent(allocation, variant, serialId); + return !sent.equals(this.entries.put(new Key(flag, subject), sent)); + } + + /** + * Gets the number of subjects remembered. + * + * @return the number of (flag, subject) entries. + */ + public synchronized int size() { + return this.entries.size(); + } + + /** Forgets every recorded exposure. */ + public synchronized void clear() { + this.entries.clear(); + } + + private static final class Key { + private final String flag; + private final String subject; + + Key(final String flag, final String subject) { + this.flag = flag; + this.subject = subject; + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof Key)) { + return false; + } + final Key key = (Key) o; + return Objects.equals(this.flag, key.flag) && Objects.equals(this.subject, key.subject); + } + + @Override + public int hashCode() { + return Objects.hash(this.flag, this.subject); + } + } + + private static final class Sent { + private final String allocation; + private final String variant; + private final Integer serialId; + + Sent(final String allocation, final String variant, final Integer serialId) { + this.allocation = allocation; + this.variant = variant; + this.serialId = serialId; + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof Sent)) { + return false; + } + final Sent entry = (Sent) o; + return Objects.equals(this.allocation, entry.allocation) + && Objects.equals(this.variant, entry.variant) + && Objects.equals(this.serialId, entry.serialId); + } + + @Override + public int hashCode() { + return Objects.hash(this.allocation, this.variant, this.serialId); + } + } + + private static final class BoundedLru extends LinkedHashMap { + private final int capacity; + + BoundedLru(final int capacity) { + super(16, 0.75f, true); + this.capacity = capacity; + } + + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return size() > this.capacity; + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java index c0385845ce2..78199967b50 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java @@ -11,7 +11,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** Deduplicates and batches exposure events to the event platform {@code exposures} route. */ +/** Batches exposure events to the event platform {@code exposures} route. */ public class ExposurePipeline implements Consumer, AutoCloseable { private static final Logger LOGGER = LoggerFactory.getLogger(ExposurePipeline.class); @@ -68,7 +68,6 @@ private static class ExposureSerializingHandler implements Runnable { private final EventTransport transport; private final Map context; - private final ExposureCache cache; private final List buffer = new ArrayList<>(); @@ -79,7 +78,6 @@ private static class ExposureSerializingHandler implements Runnable { final TimeUnit timeUnit, final Map context) { this.queue = queue; - this.cache = new LRUExposureCache(queue.capacity()); this.transport = transport; this.context = context; @@ -102,28 +100,18 @@ public void run() { private void runDutyCycle() throws InterruptedException { final Thread thread = Thread.currentThread(); while (!thread.isInterrupted()) { - ExposureEvent event; - while ((event = queue.poll(100, TimeUnit.MILLISECONDS)) != null) { - if (addToBuffer(event)) { - consumeBatch(); - break; - } + // The provider already deduplicated the exposures. + final ExposureEvent event = queue.poll(100, TimeUnit.MILLISECONDS); + if (event != null) { + this.buffer.add(event); + consumeBatch(); } flushIfNecessary(); } } private void consumeBatch() { - queue.drain(this::addToBuffer, queue.size()); - } - - /** Adds an element to the buffer taking care of duplicated exposures thanks to the LRU cache */ - private boolean addToBuffer(final ExposureEvent event) { - if (cache.add(event)) { - buffer.add(event); - return true; - } - return false; + queue.drain(this.buffer::add, queue.size()); } protected void flushIfNecessary() { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java deleted file mode 100644 index 8a83e6df745..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/LRUExposureCache.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.datadog.openfeature.internal.exposure; - -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * This class is intentionally not thread-safe. Thread safety is ensured by the single-threaded - * access pattern managed by {@link ExposureWriterImpl.ExposureSerializingHandler}. - */ -public class LRUExposureCache implements ExposureCache { - - private final Map cache; - - public LRUExposureCache(final int capacity) { - cache = - new LinkedHashMap(16, 0.75f, true) { - @Override - protected boolean removeEldestEntry(final Map.Entry eldest) { - return super.size() > capacity; - } - }; - } - - @Override - public boolean add(final ExposureEvent event) { - final Key key = new Key(event); - final Value value = new Value(event); - final Value oldValue = cache.put(key, value); - return oldValue == null || !oldValue.equals(value); - } - - @Override - public Value get(final Key key) { - return cache.get(key); - } - - @Override - public int size() { - return cache.size(); - } -} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java index 923648f8a79..359f3b65c7b 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java @@ -1,5 +1,7 @@ package com.datadog.openfeature; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; import static java.util.Collections.emptyList; import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; @@ -31,16 +33,14 @@ void enrichmentMetadataCarriesTheSplitSerialId() { final ProviderEvaluation result = evaluate(340132); assertNotNull( - result.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG), + result.getFlagMetadata().getBoolean(DO_LOG), "span enrichment must be on, or the assertions below pass vacuously"); - assertEquals( - Integer.valueOf(340132), - result.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + assertEquals(Integer.valueOf(340132), result.getFlagMetadata().getInteger(SPLIT_SERIAL_ID)); } @Test void enrichmentMetadataOmitsTheSerialIdWhenTheSplitHasNone() { - assertNull(evaluate(null).getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + assertNull(evaluate(null).getFlagMetadata().getInteger(SPLIT_SERIAL_ID)); } private static ProviderEvaluation evaluate(final Integer serialId) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java index 507d1bca061..964c9b4e149 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java @@ -1,5 +1,6 @@ package com.datadog.openfeature; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.OBSERVE_FULL_EVALUATION_DATA; import static dev.openfeature.sdk.Reason.ERROR; import static java.util.Arrays.asList; import static java.util.Collections.emptyList; @@ -15,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -23,6 +25,7 @@ import com.datadog.openfeature.internal.LocalHttpServer; import com.datadog.openfeature.internal.config.TestSettings; import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ConditionConfiguration; @@ -38,6 +41,9 @@ import com.datadog.openfeature.internal.ufc.Variant; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; @@ -256,9 +262,7 @@ public void observeFullEvaluationDataStampedTrueOnResolvedVariant() { assertThat(details.getReason(), equalTo("STATIC")); assertThat(details.getVariant(), equalTo("on")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(true)); } @Test @@ -270,9 +274,7 @@ public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { assertThat(details.getReason(), equalTo("STATIC")); assertThat(details.getVariant(), equalTo("on")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(false)); } // -- DISABLED path: flag.enabled=false -- @@ -282,9 +284,7 @@ public void observeFullEvaluationDataStampedTrueOnDisabledFlag() { final ProviderEvaluation details = evaluateDisabledFlag(true); assertThat(details.getReason(), equalTo("DISABLED")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(true)); } @Test @@ -292,9 +292,7 @@ public void observeFullEvaluationDataStampedFalseOnDisabledFlag() { final ProviderEvaluation details = evaluateDisabledFlag(false); assertThat(details.getReason(), equalTo("DISABLED")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(false)); } // -- DEFAULT path: no allocation matches -- @@ -306,9 +304,7 @@ public void observeFullEvaluationDataStampedTrueOnDefault() { final ProviderEvaluation details = evaluateWithEmptySplits(true); assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(true)); } @Test @@ -316,9 +312,7 @@ public void observeFullEvaluationDataStampedFalseOnDefault() { final ProviderEvaluation details = evaluateWithEmptySplits(false); assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(false)); } // -- error paths: FLAG_NOT_FOUND / PROVIDER_NOT_READY (via consentMetadata in error()) -- @@ -335,9 +329,7 @@ public void observeFullEvaluationDataStampedOnFlagNotFoundError() { evaluator.evaluate(Integer.class, "unknown-flag", 23, ctx); assertThat(details.getErrorCode(), equalTo(ErrorCode.FLAG_NOT_FOUND)); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(true)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(true)); } @Test @@ -346,9 +338,7 @@ public void observeFullEvaluationDataDefaultsToFalseWhenEvaluatorHasNoConfig() { final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, mock(EvaluationContext.class)); assertThat(details.getErrorCode(), equalTo(ErrorCode.PROVIDER_NOT_READY)); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(false)); } @Test @@ -366,9 +356,7 @@ public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { // Flags still evaluate — availability preserved despite the malformed consent field. assertThat(details.getReason(), equalTo("DEFAULT")); - assertThat( - details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), - equalTo(false)); + assertThat(details.getFlagMetadata().getBoolean(OBSERVE_FULL_EVALUATION_DATA), equalTo(false)); } // ---- exposure events carry the split's serial id ---- @@ -389,14 +377,27 @@ public void exposureOmitsSerialIdWhenTheSplitHasNone() { } /** - * Evaluates a logging allocation whose split carries the given serial id and returns the single - * dispatched exposure. Span enrichment is off here, as it is by default, so this also pins that - * the serial id does not travel via the enrichment-gated evaluation metadata. + * Evaluates a logging allocation whose split carries the given serial id, then runs the exposure + * hook as OpenFeature would after a successful evaluation, and returns the single dispatched + * exposure. Span enrichment is off here, as it is by default, so this also pins that the serial + * id reaches the hook without span enrichment. */ + @SuppressWarnings({"unchecked", "rawtypes"}) private static ExposureEvent exposureFor(final Integer serialId) { final List dispatched = new ArrayList<>(); + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()) { + @Override + ExposureDeduplicationCache exposureCache() { + return cache; + } + + @Override + boolean isExposureDeliveryEnabled() { + return true; + } + @Override void recordExposure(final ExposureEvent event) { dispatched.add(event); @@ -412,8 +413,24 @@ void recordExposure(final ExposureEvent event) { "target", new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); evaluator.accept(new ServerConfiguration("", "", true, null, flags)); - evaluator.evaluate( - Integer.class, "target", 23, new MutableContext("target").setTargetingKey("user-1")); + final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); + + final ProviderEvaluation evaluation = + evaluator.evaluate(Integer.class, "target", 23, ctx); + assertTrue(dispatched.isEmpty(), "resolving a flag must not send an exposure by itself"); + final HookContext hookContext = + HookContext.builder() + .flagKey("target") + .type(FlagValueType.INTEGER) + .defaultValue(23) + .ctx(ctx) + .build(); + new ExposureLoggingHook<>(() -> evaluator) + .finallyAfter( + hookContext, + (FlagEvaluationDetails) FlagEvaluationDetails.from(evaluation, "target"), + emptyMap()); + assertEquals(1, dispatched.size()); return dispatched.get(0); } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DatadogExposureLoggingTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DatadogExposureLoggingTest.java new file mode 100644 index 00000000000..594b48568e9 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DatadogExposureLoggingTest.java @@ -0,0 +1,154 @@ +package com.datadog.openfeature; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.SECONDS; +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 com.datadog.openfeature.Provider.Options; +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Turns Datadog exposure logging off through the real setting, read by the runtime. */ +class DatadogExposureLoggingTest { + + private final List sentToDatadog = new CopyOnWriteArrayList<>(); + private final List evaluations = new ArrayList<>(); + private LocalHttpServer cdn; + + @BeforeEach + void setUp() throws IOException { + this.cdn = new LocalHttpServer(); + } + + @AfterEach + void tearDown() { + OpenFeatureAPI.getInstance().clearHooks(); + OpenFeatureAPI.getInstance().shutdown(); + this.cdn.close(); + } + + @Test + void sendsNoExposuresToDatadogWhileExposureHooksKeepTheirDecisions() throws Exception { + final Client client = start("false"); + + client.getIntegerDetails("flag", 0, new MutableContext("user-1")); + client.getIntegerDetails("flag", 0, new MutableContext("user-1")); + + assertTrue(this.sentToDatadog.isEmpty(), "nothing is sent to Datadog"); + assertEquals(2, this.evaluations.size()); + assertTrue(this.evaluations.get(0).shouldSend()); + assertFalse(this.evaluations.get(1).shouldSend(), "the cache still records the first exposure"); + } + + @Test + void sendsExposuresToDatadogByDefault() throws Exception { + final Client client = start(null); + + client.getIntegerDetails("flag", 0, new MutableContext("user-1")); + client.getIntegerDetails("flag", 0, new MutableContext("user-1")); + + assertEquals(1, this.sentToDatadog.size()); + assertEquals("user-1", this.sentToDatadog.get(0).subject.id); + } + + private Client start(final String exposuresDatadogLoggingEnabled) throws Exception { + final List settings = new ArrayList<>(); + settings.add("feature.flags.configuration.source.agentless.base.url"); + settings.add(this.cdn.uri("/").toString()); + settings.add("flagging.evaluation.counts.enabled"); + settings.add("false"); + if (exposuresDatadogLoggingEnabled != null) { + settings.add("feature.flags.exposures.datadog.logging.enabled"); + settings.add(exposuresDatadogLoggingEnabled); + } + final DDEvaluator evaluator = + new DDEvaluator( + () -> {}, + new ProxyConnector((route, json) -> {}), + TestSettings.of(settings.toArray(new String[0]))) { + @Override + void recordExposure(final ExposureEvent event) { + DatadogExposureLoggingTest.this.sentToDatadog.add(event); + } + }; + evaluator.accept(configuration()); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), evaluator)); + api.addHooks(new ExposureHook(this.evaluations::add)); + return api.getClient(); + } + + private static ServerConfiguration configuration() { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), 7); + final Allocation allocation = + new Allocation("allocation", null, null, null, singletonList(split), Boolean.TRUE); + final Map flags = new HashMap<>(); + flags.put( + "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); + return new ServerConfiguration("", "", false, null, flags); + } + + /** A connector only providing an event platform proxy. */ + private static final class ProxyConnector implements Connector { + private final EventTransport proxy; + + ProxyConnector(final EventTransport proxy) { + this.proxy = proxy; + } + + @Override + public String setting(final String key) { + return null; + } + + @Override + public ConfigurationSource remoteConfiguration() { + return null; + } + + @Override + public EventTransport eventProxy() { + return this.proxy; + } + + @Override + public HealthMetrics healthMetrics() { + return HealthMetrics.NOOP; + } + + @Override + public SpanEnricher spanEnricher() { + return SpanEnricher.NOOP; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureHookTest.java new file mode 100644 index 00000000000..81aae5bbbda --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureHookTest.java @@ -0,0 +1,440 @@ +package com.datadog.openfeature; + +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.ALLOCATION_KEY; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EVALUATION_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_CACHE_HIT; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.FEATURE_PREFIX; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.Provider.Options; +import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; +import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Feature; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ExposureHookTest { + + private final List evaluations = new ArrayList<>(); + private ExposureTestEvaluator evaluator; + private Client client; + + @AfterEach + void tearDown() { + OpenFeatureAPI.getInstance().clearHooks(); + OpenFeatureAPI.getInstance().shutdown(); + } + + @Test + void shouldSendMatchesTheExposuresSentToDatadog() throws Exception { + start(true, configuration(Boolean.TRUE, null)); + + evaluate("user-1"); + evaluate("user-1"); + evaluate("user-2"); + + assertEquals(asList(true, false, true), shouldSend()); + assertEquals(2, this.evaluator.sentToDatadog.size()); + assertEquals("user-1", this.evaluator.sentToDatadog.get(0).subject.id); + assertEquals("user-2", this.evaluator.sentToDatadog.get(1).subject.id); + } + + @Test + void customerHookStillReceivesExposuresWhenDatadogLoggingIsOff() throws Exception { + start(false, configuration(Boolean.TRUE, null)); + + evaluate("user-1"); + evaluate("user-1"); + evaluate("user-2"); + + assertTrue(this.evaluator.sentToDatadog.isEmpty()); + assertEquals(asList(true, false, true), shouldSend()); + } + + @Test + void aFailedEvaluationIsNotAnExposure() throws Exception { + start(true, configuration(Boolean.TRUE, null)); + OpenFeatureAPI.getInstance() + .addHooks( + new Hook() { + @Override + public void after( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + throw new IllegalStateException("application validation failed"); + } + }); + + final FlagEvaluationDetails details = evaluate("user-1"); + + assertEquals(ErrorCode.GENERAL, details.getErrorCode()); + final ExposureHook.Evaluation evaluation = this.evaluations.get(0); + assertFalse(evaluation.isExposure()); + assertFalse(evaluation.isCacheHit()); + assertFalse(evaluation.shouldSend()); + } + + @Test + void anAllocationThatDoesNotLogExposuresIsNotAnExposure() throws Exception { + start(true, configuration(Boolean.FALSE, null)); + + evaluate("user-1"); + + final ExposureHook.Evaluation evaluation = this.evaluations.get(0); + assertFalse(evaluation.isExposure()); + assertFalse(evaluation.isCacheHit()); + assertFalse(evaluation.shouldSend()); + assertEquals("flag", evaluation.getDetails().getFlagKey()); + assertEquals("user-1", evaluation.getContext().getTargetingKey()); + } + + @Test + void exposesTheFeaturesOfTheSelectedSplit() throws Exception { + start( + true, + configuration( + Boolean.TRUE, + asList( + new Feature("holdout.key", "q4-global", singletonList("HOOK")), + new Feature("holdout.assignment_group", "status_quo", singletonList("HOOK")), + new Feature("holdout.weight", 0.5, asList("EVALUATION", "HOOK")), + new Feature( + "holdout.should_include_in_holdout_analysis", + true, + asList("HOOK", "SOME_FUTURE_DESTINATION")), + new Feature("bandit.policy_id", "p2", asList("EXPOSURE", "EVALUATION")), + new Feature("unknown.only", "x", singletonList("SOME_FUTURE_DESTINATION"))))); + + final FlagEvaluationDetails details = evaluate("user-1"); + + final Map features = this.evaluations.get(0).getFeatures(); + assertEquals(4, features.size()); + assertFalse( + features.containsKey("bandit.policy_id"), + "a feature without HOOK is not delivered to hooks"); + assertFalse( + features.containsKey("unknown.only"), + "a feature with only unknown destinations goes nowhere"); + assertEquals("q4-global", features.get("holdout.key")); + assertEquals("status_quo", features.get("holdout.assignment_group")); + assertEquals(0.5, features.get("holdout.weight")); + assertEquals(true, features.get("holdout.should_include_in_holdout_analysis")); + assertEquals( + "q4-global", + details.getFlagMetadata().asUnmodifiableMap().get(FEATURE_PREFIX + "holdout.key")); + assertThrows(UnsupportedOperationException.class, () -> features.put("other", "value")); + } + + @Test + void routesEachFeatureToItsDestinations() throws Exception { + start( + true, + configuration( + Boolean.TRUE, + asList( + new Feature("holdout.key", "q4-global", singletonList("HOOK")), + new Feature("bandit.arm", "a", singletonList("EXPOSURE")), + new Feature("experiment.tag", "pricing", singletonList("EVALUATION")), + new Feature("bandit.policy_id", "p2", asList("EXPOSURE", "EVALUATION"))))); + + final FlagEvaluationDetails details = evaluate("user-1"); + + final ImmutableMetadata metadata = details.getFlagMetadata(); + assertEquals( + singletonMap("holdout.key", "q4-global"), + SplitFeatureMetadata.featuresWithPrefix(metadata, FEATURE_PREFIX)); + final Map exposureFeatures = new HashMap<>(); + exposureFeatures.put("bandit.arm", "a"); + exposureFeatures.put("bandit.policy_id", "p2"); + assertEquals( + exposureFeatures, + SplitFeatureMetadata.featuresWithPrefix(metadata, EXPOSURE_FEATURE_PREFIX)); + final Map evaluationFeatures = new HashMap<>(); + evaluationFeatures.put("experiment.tag", "pricing"); + evaluationFeatures.put("bandit.policy_id", "p2"); + assertEquals( + evaluationFeatures, + SplitFeatureMetadata.featuresWithPrefix(metadata, EVALUATION_FEATURE_PREFIX)); + assertEquals(singletonMap("holdout.key", "q4-global"), this.evaluations.get(0).getFeatures()); + assertEquals(exposureFeatures, this.evaluator.sentToDatadog.get(0).features); + } + + @Test + void aRepeatedKeyKeepsTheFirstFeatureForEveryDestination() throws Exception { + start( + true, + configuration( + Boolean.TRUE, + asList( + new Feature("holdout.key", "q4-global", singletonList("HOOK")), + new Feature("holdout.key", "q1-global", asList("HOOK", "EXPOSURE"))))); + + final FlagEvaluationDetails details = evaluate("user-1"); + + assertEquals(singletonMap("holdout.key", "q4-global"), this.evaluations.get(0).getFeatures()); + assertTrue( + SplitFeatureMetadata.featuresWithPrefix(details.getFlagMetadata(), EXPOSURE_FEATURE_PREFIX) + .isEmpty(), + "the repeated feature's EXPOSURE destination is ignored with it"); + assertNull(this.evaluator.sentToDatadog.get(0).features); + } + + @Test + void sendsEvaluationFeaturesForAnAllocationThatDoesNotLogExposures() throws Exception { + final AtomicReference captured = new AtomicReference<>(); + start( + true, + configuration( + Boolean.FALSE, + singletonList(new Feature("bandit.policy_id", "p2", singletonList("EVALUATION"))))); + final FlagEvaluationWriter writer = capturingWriter(captured); + OpenFeatureAPI.getInstance().addHooks(new FlagEvalLoggingHook<>(() -> writer)); + + evaluate("user-1"); + + assertEquals(singletonMap("bandit.policy_id", "p2"), captured.get().features); + assertTrue(this.evaluator.sentToDatadog.isEmpty(), "the allocation does not log exposures"); + } + + @Test + void hasNoFeaturesWhenTheSplitHasNone() throws Exception { + start(true, configuration(Boolean.TRUE, null)); + + evaluate("user-1"); + + assertTrue(this.evaluations.get(0).getFeatures().isEmpty()); + } + + @Test + void aFailingCallbackDoesNotAffectTheEvaluation() throws Exception { + start(true, configuration(Boolean.TRUE, null)); + OpenFeatureAPI.getInstance() + .addHooks( + new ExposureHook( + evaluation -> { + throw new IllegalStateException("warehouse unavailable"); + })); + + final FlagEvaluationDetails details = evaluate("user-1"); + + assertNull(details.getErrorCode()); + assertEquals(Integer.valueOf(1), details.getValue()); + assertEquals(1, this.evaluator.sentToDatadog.size()); + } + + @Test + void anEvaluationIsAnExposureOnlyWithEverythingAnExposureNeeds() { + assertFalse(evaluation("on", null).isExposure()); + assertFalse(evaluation(null, exposureMetadata("allocation", true, false)).isExposure()); + assertFalse(evaluation("on", exposureMetadata(null, true, false)).isExposure()); + assertFalse(evaluation("on", exposureMetadata("allocation", false, false)).isExposure()); + assertTrue(evaluation("on", exposureMetadata("allocation", true, false)).shouldSend()); + assertFalse(evaluation("on", exposureMetadata("allocation", true, false)).isCacheHit()); + assertTrue(evaluation("on", exposureMetadata("allocation", true, true)).isCacheHit()); + assertFalse( + evaluation("on", exposureMetadata("allocation", false, true)).isCacheHit(), + "an evaluation that is not an exposure is never a cache hit"); + assertFalse(evaluation("on", exposureMetadata("allocation", true, true)).shouldSend()); + } + + @Test + void anEvaluationWithAnErrorIsNotAnExposure() { + final ExposureHook.Evaluation evaluation = + new ExposureHook.Evaluation( + new MutableContext("user-1"), + FlagEvaluationDetails.builder() + .flagKey("flag") + .value(0) + .variant("on") + .errorCode(ErrorCode.GENERAL) + .flagMetadata(exposureMetadata("allocation", true, false)) + .build()); + + assertFalse(evaluation.isExposure()); + assertFalse(evaluation.shouldSend()); + } + + @Test + void featuresIgnoreOtherMetadataAndAreEmptyWithoutMetadata() { + assertTrue(evaluation("on", null).getFeatures().isEmpty()); + final ExposureHook.Evaluation evaluation = + evaluation( + "on", + ImmutableMetadata.builder() + .addString(ALLOCATION_KEY, "allocation") + .addString(FEATURE_PREFIX + "holdout.key", "q4-global") + .build()); + + assertEquals(1, evaluation.getFeatures().size()); + assertEquals("q4-global", evaluation.getFeatures().get("holdout.key")); + } + + @Test + void theHookContainsCallbackFailures() { + final HookContext context = hookContext(); + final FlagEvaluationDetails details = + FlagEvaluationDetails.builder().flagKey("flag").value(1).build(); + + assertDoesNotThrow( + () -> + new ExposureHook( + evaluation -> { + throw new IllegalStateException("warehouse unavailable"); + }) + .finallyAfter(context, details, emptyMap())); + assertDoesNotThrow( + () -> + new ExposureHook( + evaluation -> { + throw new NoClassDefFoundError("missing class"); + }) + .finallyAfter(context, details, emptyMap())); + } + + @Test + void keepsContainingCallbackFailuresAfterTheFirstOne() { + final ExposureHook hook = + new ExposureHook( + evaluation -> { + throw new IllegalStateException("warehouse unavailable"); + }); + final FlagEvaluationDetails details = + FlagEvaluationDetails.builder().flagKey("flag").value(1).build(); + + hook.finallyAfter(hookContext(), details, emptyMap()); + + assertDoesNotThrow(() -> hook.finallyAfter(hookContext(), details, emptyMap())); + } + + @Test + void requiresACallback() { + final NullPointerException error = + assertThrows(NullPointerException.class, () -> new ExposureHook(null)); + assertEquals("callback", error.getMessage()); + } + + @Test + void skipsTheCallbackWhenThereAreNoDetails() { + final List seen = new ArrayList<>(); + + new ExposureHook(seen::add).finallyAfter(hookContext(), null, emptyMap()); + + assertTrue(seen.isEmpty()); + } + + private void start(final boolean sendToDatadog, final ServerConfiguration configuration) + throws Exception { + this.evaluator = new ExposureTestEvaluator(configuration, sendToDatadog); + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), this.evaluator)); + api.addHooks(new ExposureHook(this.evaluations::add)); + this.client = api.getClient(); + } + + private static HookContext hookContext() { + return HookContext.builder() + .flagKey("flag") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(new MutableContext("user-1")) + .build(); + } + + private static ExposureHook.Evaluation evaluation( + final String variant, final ImmutableMetadata metadata) { + final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = + FlagEvaluationDetails.builder().flagKey("flag").value(1).variant(variant); + if (metadata != null) { + builder.flagMetadata(metadata); + } + return new ExposureHook.Evaluation(new MutableContext("user-1"), builder.build()); + } + + private static ImmutableMetadata exposureMetadata( + final String allocationKey, final boolean doLog, final boolean cacheHit) { + final ImmutableMetadata.ImmutableMetadataBuilder builder = + ImmutableMetadata.builder() + .addBoolean(DO_LOG, doLog) + .addBoolean(EXPOSURE_CACHE_HIT, cacheHit); + if (allocationKey != null) { + builder.addString(ALLOCATION_KEY, allocationKey); + } + return builder.build(); + } + + private FlagEvaluationDetails evaluate(final String targetingKey) { + return this.client.getIntegerDetails("flag", 0, new MutableContext(targetingKey)); + } + + private List shouldSend() { + final List result = new ArrayList<>(); + for (final ExposureHook.Evaluation evaluation : this.evaluations) { + result.add(evaluation.shouldSend()); + } + return result; + } + + private static ServerConfiguration configuration( + final Boolean doLog, final List features) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), 7, features); + final Allocation allocation = + new Allocation("allocation", null, null, null, singletonList(split), doLog); + final Map flags = new HashMap<>(); + flags.put( + "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); + return new ServerConfiguration("", "", false, null, flags); + } + + private static FlagEvaluationWriter capturingWriter(final AtomicReference ref) { + final FlagEvaluationWriter writer = mock(FlagEvaluationWriter.class); + when(writer.hasCapacityForEnqueue()).thenReturn(true); + doAnswer( + invocation -> { + ref.set(invocation.getArgument(0)); + return null; + }) + .when(writer) + .enqueue(any()); + return writer; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java new file mode 100644 index 00000000000..b8ee1ed2497 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java @@ -0,0 +1,307 @@ +package com.datadog.openfeature; + +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.ALLOCATION_KEY; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_CACHE_HIT; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.Provider.Options; +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.multiprovider.MultiProvider; +import java.util.HashMap; +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; + +class ExposureLoggingHookTest { + + private ExposureTestEvaluator evaluator; + private List dispatched; + private Client client; + + @BeforeEach + void setUp() throws Exception { + this.evaluator = new ExposureTestEvaluator(configuration("on", Boolean.TRUE), true); + this.dispatched = this.evaluator.sentToDatadog; + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new Provider(new Options().initTimeout(10, SECONDS), this.evaluator)); + this.client = api.getClient(); + } + + @AfterEach + void tearDown() { + OpenFeatureAPI.getInstance().clearHooks(); + OpenFeatureAPI.getInstance().shutdown(); + } + + @Test + void sendsExposuresWhenWrappedInAMultiProvider() throws Exception { + final ExposureTestEvaluator wrapped = + new ExposureTestEvaluator(configuration("on", Boolean.TRUE), true); + OpenFeatureAPI.getInstance() + .setProviderAndWait( + new MultiProvider( + singletonList(new Provider(new Options().initTimeout(10, SECONDS), wrapped)))); + + evaluate("user-1"); + evaluate("user-1"); + + assertEquals(1, wrapped.sentToDatadog.size()); + assertEquals("user-1", wrapped.sentToDatadog.get(0).subject.id); + } + + @Test + void sendsOneExposurePerSubject() { + evaluate("user-1"); + evaluate("user-1"); + evaluate("user-2"); + + assertEquals(2, this.dispatched.size()); + assertEquals("user-1", this.dispatched.get(0).subject.id); + assertEquals("user-2", this.dispatched.get(1).subject.id); + assertEquals("flag", this.dispatched.get(0).flag.key); + assertEquals("allocation", this.dispatched.get(0).allocation.key); + assertEquals("on", this.dispatched.get(0).variant.key); + assertEquals(Integer.valueOf(7), this.dispatched.get(0).serial_id); + assertNull(this.dispatched.get(0).features); + } + + @Test + void stampsWhetherTheSubjectWasAlreadyExposed() { + assertEquals(Boolean.FALSE, cacheHit(evaluate("user-1"))); + assertEquals(Boolean.TRUE, cacheHit(evaluate("user-1"))); + } + + @Test + void sendsAgainWhenTheVariantChanges() { + evaluate("user-1"); + this.evaluator.accept(configuration("off", Boolean.TRUE)); + evaluate("user-1"); + + assertEquals(2, this.dispatched.size()); + assertEquals("off", this.dispatched.get(1).variant.key); + } + + @Test + void aFailedEvaluationSendsNothingAndLeavesTheSubjectUnexposed() { + final Hook failingAfterHook = + new Hook() { + @Override + public void after( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + throw new IllegalStateException("application validation failed"); + } + }; + OpenFeatureAPI.getInstance().addHooks(failingAfterHook); + + final FlagEvaluationDetails failed = evaluate("user-1"); + + assertEquals(ErrorCode.GENERAL, failed.getErrorCode()); + assertTrue(this.dispatched.isEmpty()); + assertEquals(0, this.evaluator.cache.size()); + + OpenFeatureAPI.getInstance().clearHooks(); + evaluate("user-1"); + + assertEquals(1, this.dispatched.size()); + } + + @Test + void sendsNothingForAnAllocationThatDoesNotLogExposures() { + this.evaluator.accept(configuration("on", Boolean.FALSE)); + + final FlagEvaluationDetails details = evaluate("user-1"); + + assertTrue(this.dispatched.isEmpty()); + assertNull(cacheHit(details)); + } + + @Test + void recordsButDoesNotSendWhenDatadogLoggingIsOff() { + final ExposureTestEvaluator notSending = + new ExposureTestEvaluator(configuration("on", Boolean.TRUE), false); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> notSending); + + hook.finallyAfter(hookContext(), details("on", exposureMetadata(true, false, "a")), emptyMap()); + + assertTrue(notSending.sentToDatadog.isEmpty()); + assertEquals(1, notSending.cache.size()); + } + + @Test + void sendsNothingWithoutTheDetailsAnExposureNeeds() { + final ExposureTestEvaluator target = + new ExposureTestEvaluator(configuration("on", Boolean.TRUE), true); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> target); + final HookContext withContext = hookContext(); + + hook.finallyAfter(withContext, null, emptyMap()); + hook.finallyAfter(withContext, details("variant", null), emptyMap()); + hook.finallyAfter( + withContext, details("variant", exposureMetadata(false, false, "a")), emptyMap()); + hook.finallyAfter( + withContext, details("variant", exposureMetadata(true, true, "a")), emptyMap()); + hook.finallyAfter( + withContext, details("variant", exposureMetadata(true, false, null)), emptyMap()); + hook.finallyAfter(withContext, details(null, exposureMetadata(true, false, "a")), emptyMap()); + hook.finallyAfter(null, details("variant", exposureMetadata(true, false, "a")), emptyMap()); + hook.finallyAfter( + withContext, + FlagEvaluationDetails.builder() + .flagKey("flag") + .value(1) + .variant("variant") + .errorCode(ErrorCode.GENERAL) + .flagMetadata(exposureMetadata(true, false, "a")) + .build(), + emptyMap()); + + assertTrue(target.sentToDatadog.isEmpty()); + assertEquals(0, target.cache.size()); + + hook.finallyAfter( + withContext, details("variant", exposureMetadata(true, false, "a")), emptyMap()); + + assertEquals(1, target.sentToDatadog.size()); + assertEquals(1, target.cache.size()); + } + + @Test + void sendsNothingWithoutAnInitializedDatadogEvaluator() { + final ExposureLoggingHook withoutEvaluator = new ExposureLoggingHook<>(() -> null); + final DDEvaluator uninitialized = new DDEvaluator(() -> {}, Connector.NONE, TestSettings.of()); + final ExposureLoggingHook withoutRuntime = + new ExposureLoggingHook<>(() -> uninitialized); + + assertDoesNotThrow( + () -> + withoutEvaluator.finallyAfter( + hookContext(), details("on", exposureMetadata(true, false, "a")), emptyMap())); + assertDoesNotThrow( + () -> + withoutRuntime.finallyAfter( + hookContext(), details("on", exposureMetadata(true, false, "a")), emptyMap())); + } + + @Test + void readsTheSerialIdFromTheMetadata() { + final ExposureTestEvaluator target = + new ExposureTestEvaluator(configuration("on", Boolean.TRUE), true); + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> target); + final ImmutableMetadata metadata = + ImmutableMetadata.builder() + .addBoolean(DO_LOG, true) + .addBoolean(EXPOSURE_CACHE_HIT, false) + .addString(ALLOCATION_KEY, "a") + .addInteger(SPLIT_SERIAL_ID, 42) + .build(); + + hook.finallyAfter(hookContext(), details("on", metadata), emptyMap()); + + assertEquals(Integer.valueOf(42), target.sentToDatadog.get(0).serial_id); + assertTrue(target.cache.contains("flag", "user-1", "a", "on", 42)); + } + + @Test + void containsFailuresOfTheEvaluator() { + final ExposureTestEvaluator failing = + new ExposureTestEvaluator(configuration("on", Boolean.TRUE), true) { + @Override + void recordExposure(final ExposureEvent event) { + throw new IllegalStateException("queue failure"); + } + }; + final ExposureLoggingHook hook = new ExposureLoggingHook<>(() -> failing); + final HookContext context = hookContext(); + + assertDoesNotThrow( + () -> + hook.finallyAfter( + context, details("on", exposureMetadata(true, false, "a")), emptyMap())); + assertDoesNotThrow( + () -> + hook.finallyAfter( + context, details("off", exposureMetadata(true, false, "a")), emptyMap())); + } + + private static Object cacheHit(final FlagEvaluationDetails details) { + return details.getFlagMetadata().asUnmodifiableMap().get(EXPOSURE_CACHE_HIT); + } + + private static HookContext hookContext() { + return HookContext.builder() + .flagKey("flag") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(new MutableContext("user-1")) + .build(); + } + + private static FlagEvaluationDetails details( + final String variant, final ImmutableMetadata metadata) { + final FlagEvaluationDetails.FlagEvaluationDetailsBuilder builder = + FlagEvaluationDetails.builder().flagKey("flag").value(1).variant(variant); + if (metadata != null) { + builder.flagMetadata(metadata); + } + return builder.build(); + } + + private static ImmutableMetadata exposureMetadata( + final boolean doLog, final boolean cacheHit, final String allocationKey) { + final ImmutableMetadata.ImmutableMetadataBuilder builder = + ImmutableMetadata.builder() + .addBoolean(DO_LOG, doLog) + .addBoolean(EXPOSURE_CACHE_HIT, cacheHit); + if (allocationKey != null) { + builder.addString(ALLOCATION_KEY, allocationKey); + } + return builder.build(); + } + + private FlagEvaluationDetails evaluate(final String targetingKey) { + return this.client.getIntegerDetails("flag", 0, new MutableContext(targetingKey)); + } + + private static ServerConfiguration configuration(final String variantKey, final Boolean doLog) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + variations.put("off", new Variant("off", 0)); + final Split split = new Split(emptyList(), variantKey, emptyMap(), 7); + final Allocation allocation = + new Allocation("allocation", null, null, null, singletonList(split), doLog); + final Map flags = new HashMap<>(); + flags.put( + "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); + return new ServerConfiguration("", "", false, null, flags); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureTestEvaluator.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureTestEvaluator.java new file mode 100644 index 00000000000..8ec90e6c0ed --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureTestEvaluator.java @@ -0,0 +1,56 @@ +package com.datadog.openfeature; + +import static org.mockito.Mockito.mock; + +import com.datadog.openfeature.internal.config.TestSettings; +import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; +import com.datadog.openfeature.internal.exposure.ExposureEvent; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import dev.openfeature.sdk.EvaluationContext; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +/** + * A Datadog evaluator that runs without a runtime: it owns its exposure cache and captures the + * exposures it would send to Datadog. + */ +class ExposureTestEvaluator extends DDEvaluator { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(); + final List sentToDatadog = new CopyOnWriteArrayList<>(); + private final boolean sendToDatadog; + + /** + * Creates an evaluator holding a configuration. + * + * @param configuration the flag configuration to evaluate. + * @param sendToDatadog whether exposures are sent to Datadog. + */ + ExposureTestEvaluator(final ServerConfiguration configuration, final boolean sendToDatadog) { + super(mock(Runnable.class), Connector.NONE, TestSettings.of()); + this.sendToDatadog = sendToDatadog; + accept(configuration); + } + + @Override + public synchronized boolean initialize( + final long timeout, final TimeUnit unit, final EvaluationContext context) { + return hasConfiguration(); + } + + @Override + ExposureDeduplicationCache exposureCache() { + return this.cache; + } + + @Override + boolean isExposureDeliveryEnabled() { + return this.sendToDatadog; + } + + @Override + void recordExposure(final ExposureEvent event) { + this.sentToDatadog.add(event); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java index 32eb936c932..bd13dbbd7cf 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/FlagEvalLoggingHookTest.java @@ -1,5 +1,10 @@ package com.datadog.openfeature; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EVALUATION_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.HAS_EVALUATION_FEATURES; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.OBSERVE_FULL_EVALUATION_DATA; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -34,6 +39,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; /** * Unit tests for {@link FlagEvalLoggingHook}: context capture, non-blocking enqueue, eval-time @@ -113,6 +119,83 @@ void finallyAfterEnqueuesEventWithAllBasicFields() { assertEquals("alloc-1", e.allocationKey); } + @TableTest({ + "scenario | consent", + "consent on | true ", + "consent off | false " + }) + void finallyAfterAddsEvaluationFeaturesWhateverTheConsent(final boolean consent) { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + hook.finallyAfter( + null, + details( + "my-flag", + "on-value", + "on", + Reason.TARGETING_MATCH.name(), + ImmutableMetadata.builder() + .addString("allocationKey", "alloc-1") + .addBoolean(OBSERVE_FULL_EVALUATION_DATA, consent) + .addString(EVALUATION_FEATURE_PREFIX + "bandit.policy_id", "p2") + .addBoolean(HAS_EVALUATION_FEATURES, true) + .addString(FEATURE_PREFIX + "holdout.key", "q4-global") + .addString(EXPOSURE_FEATURE_PREFIX + "bandit.arm", "a") + .build()), + Collections.emptyMap()); + + assertEquals( + Collections.singletonMap("bandit.policy_id", "p2"), + captured.get().features, + "only EVALUATION features reach the event, whatever the consent"); + } + + @Test + void finallyAfterSkipsEvaluationFeaturesWithoutTheMarker() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + hook.finallyAfter( + null, + details( + "my-flag", + "on-value", + "on", + Reason.TARGETING_MATCH.name(), + ImmutableMetadata.builder() + .addString(EVALUATION_FEATURE_PREFIX + "bandit.policy_id", "p2") + .build()), + Collections.emptyMap()); + + assertTrue(captured.get().features.isEmpty()); + } + + @Test + void finallyAfterIgnoresMetadataOfTheWrongType() { + final AtomicReference captured = new AtomicReference<>(); + final FlagEvalLoggingHook hook = hookWithWriter(capturingWriter(captured)); + + hook.finallyAfter( + null, + details( + "my-flag", + "on-value", + "on", + Reason.TARGETING_MATCH.name(), + ImmutableMetadata.builder() + .addInteger("allocationKey", 1) + .addString("__dd_eval_timestamp_ms", "now") + .addString(OBSERVE_FULL_EVALUATION_DATA, "true") + .build()), + Collections.emptyMap()); + + final FlagEvalEvent event = captured.get(); + assertNull(event.allocationKey); + assertFalse(event.observeFullEvaluationData); + assertTrue(event.evalTimeMs > 0); + } + // ---- variant comes from details.getVariant(), NOT details.getValue() ---- @Test @@ -599,9 +682,7 @@ void protectedPathSkipsEvaluationContextCapture() { .ctx(context) .build(); final ImmutableMetadata consentOff = - ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .build(); + ImmutableMetadata.builder().addBoolean(OBSERVE_FULL_EVALUATION_DATA, false).build(); hook.finallyAfter( hookCtx, @@ -623,9 +704,7 @@ private FlagEvalEvent enqueuedEventWithConsentMetadata(final Boolean consent) { final ImmutableMetadata metadata = consent == null ? null - : ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, consent) - .build(); + : ImmutableMetadata.builder().addBoolean(OBSERVE_FULL_EVALUATION_DATA, consent).build(); hook.finallyAfter( hookCtxWithTargetingKey("obs-flag", "user-1"), details("obs-flag", "on", "on", Reason.TARGETING_MATCH.name(), metadata), @@ -635,14 +714,10 @@ private FlagEvalEvent enqueuedEventWithConsentMetadata(final Boolean consent) { } private static ImmutableMetadata consentOnMetadata() { - return ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) - .build(); + return ImmutableMetadata.builder().addBoolean(OBSERVE_FULL_EVALUATION_DATA, true).build(); } private static ImmutableMetadata consentOffMetadata() { - return ImmutableMetadata.builder() - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, false) - .build(); + return ImmutableMetadata.builder().addBoolean(OBSERVE_FULL_EVALUATION_DATA, false).build(); } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java index 4008aac4974..09bf50bf6ab 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java @@ -388,10 +388,12 @@ public void testGetProviderHooksReturnsFlagEvalMetricsHook() { Provider provider = new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); List hooks = provider.getProviderHooks(); - // Two hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) - assertThat(hooks.size(), equalTo(2)); + // Three hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) + + // ExposureLoggingHook (index 2) + assertThat(hooks.size(), equalTo(3)); assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); + assertThat(hooks.get(2) instanceof ExposureLoggingHook, equalTo(true)); } @Test @@ -443,6 +445,9 @@ public void testGetProviderHooksReturnsFlagEvalMetricsHookWithAndWithoutSpanEnri assertHasFlagEvalMetricsHook(providerWithoutSpanEnrichment); assertHasFlagEvalMetricsHook(providerWithSpanEnrichment); + assertExposureLoggingHookIsLast(providerWithoutSpanEnrichment); + assertExposureLoggingHookIsLast(providerWithSpanEnrichment); + assertThat(providerWithSpanEnrichment.getProviderHooks().size(), equalTo(4)); } @Test @@ -457,8 +462,16 @@ public void testShutdownCleansUpEvaluator() throws Exception { provider.shutdown(); verify(evaluator).shutdown(); - // After shutdown, getProviderHooks still returns a list with both OTel + logging hooks - assertThat(provider.getProviderHooks().size(), equalTo(2)); + // After shutdown, getProviderHooks still returns a list with the OTel, logging and exposure + // hooks + assertThat(provider.getProviderHooks().size(), equalTo(3)); + } + + private static void assertExposureLoggingHookIsLast(final Provider provider) { + final List hooks = provider.getProviderHooks(); + assertTrue( + hooks.get(hooks.size() - 1) instanceof ExposureLoggingHook, + "the exposure logging hook should run last"); } private static void assertHasFlagEvalMetricsHook(final Provider provider) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java index 6c2284153a7..73300451d42 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -2,6 +2,7 @@ import static java.util.Collections.emptyMap; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -70,12 +71,34 @@ void evaluationCountsKillSwitchDisablesTheEvaluationPipeline() { assertNull(runtime.evaluations()); } + @Test + void exposuresDatadogLoggingKillSwitchDisablesTheExposurePipelineButKeepsTheCache() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestConnector(null, (route, json) -> {}), + TestSettings.of("feature.flags.exposures.datadog.logging.enabled", "false")); + + assertFalse(runtime.isExposureDeliveryEnabled()); + assertNotNull(runtime.exposureCache()); + assertNotNull(runtime.evaluations(), "flag evaluation events are still sent"); + } + + @Test + void agentlessWithoutApiKeyOrProxyKeepsTheExposureCache() { + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + + assertFalse(runtime.isExposureDeliveryEnabled()); + assertNotNull(runtime.exposureCache()); + } + @Test void eventProxyEnablesBothPipelines() { final FeatureFlagsRuntime runtime = FeatureFlagsRuntime.create(new TestConnector(null, (route, json) -> {}), TestSettings.of()); assertNotNull(runtime.evaluations()); + assertTrue(runtime.isExposureDeliveryEnabled()); } @Test diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/FlagEvaluatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/FlagEvaluatorTest.java new file mode 100644 index 00000000000..327fc47b33e --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/evaluation/FlagEvaluatorTest.java @@ -0,0 +1,241 @@ +package com.datadog.openfeature.internal.evaluation; + +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.DO_LOG; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EVALUATION_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_CACHE_HIT; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.EXPOSURE_FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.FEATURE_PREFIX; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.HAS_EVALUATION_FEATURES; +import static com.datadog.openfeature.internal.evaluation.MetadataKeys.SPLIT_SERIAL_ID; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Feature; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.MutableContext; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class FlagEvaluatorTest { + + @AfterEach + void tearDown() { + FlagEvaluator.WARNED_FEATURE_DESTINATIONS.clear(); + } + + @TableTest({ + "scenario | enrichment | doLog | serialId | hasDoLog | hasSerialId", + "neither enrichment nor log | false | false | 7 | false | false ", + "log without enrichment | false | true | 7 | true | true ", + "enrichment without log | true | false | 7 | true | true ", + "log without serial id | false | true | | true | false ", + "enrichment and log | true | true | 0 | true | true " + }) + void emitsTheExposureMetadataWhenEnrichingSpansOrLoggingExposures( + final boolean enrichment, + final boolean doLog, + final Integer serialId, + final boolean hasDoLog, + final boolean hasSerialId) { + final Map metadata = + evaluate(new FlagEvaluator(enrichment, notExposed()), doLog, serialId, null); + + assertEquals(hasDoLog, metadata.containsKey(DO_LOG)); + if (hasDoLog) { + assertEquals(doLog, metadata.get(DO_LOG)); + } + assertEquals(hasSerialId ? serialId : null, metadata.get(SPLIT_SERIAL_ID)); + assertEquals(doLog, metadata.containsKey(EXPOSURE_CACHE_HIT)); + } + + @Test + void stampsWhetherTheExposureWasAlreadyRecorded() { + final List lookups = new ArrayList<>(); + final FlagEvaluator evaluator = + new FlagEvaluator( + false, + (flag, subject, allocation, variant, serialId) -> { + lookups.add(flag + "/" + subject + "/" + allocation + "/" + variant + "/" + serialId); + return "user-2".equals(subject); + }); + + assertEquals(Boolean.FALSE, evaluate(evaluator, "user-1").get(EXPOSURE_CACHE_HIT)); + assertEquals(Boolean.TRUE, evaluate(evaluator, "user-2").get(EXPOSURE_CACHE_HIT)); + assertEquals(asList("flag/user-1/allocation/on/7", "flag/user-2/allocation/on/7"), lookups); + } + + @Test + void doesNotLookUpExposuresForAnAllocationThatDoesNotLogThem() { + final FlagEvaluator evaluator = + new FlagEvaluator( + true, + (flag, subject, allocation, variant, serialId) -> { + throw new AssertionError("no exposure lookup expected"); + }); + + assertFalse(evaluate(evaluator, false, 7, null).containsKey(EXPOSURE_CACHE_HIT)); + } + + @Test + void routesEachFeatureToTheDestinationsItLists() { + final Map metadata = + evaluate( + new FlagEvaluator(false, notExposed()), + false, + null, + asList( + new Feature("holdout.key", "q4-global", singletonList("HOOK")), + new Feature("bandit.arm", "a", singletonList("EXPOSURE")), + new Feature("holdout.weight", 1, asList("HOOK", "EVALUATION")), + new Feature("holdout.enabled", true, singletonList("EXPOSURE")))); + + assertEquals("q4-global", metadata.get(FEATURE_PREFIX + "holdout.key")); + assertEquals("a", metadata.get(EXPOSURE_FEATURE_PREFIX + "bandit.arm")); + assertEquals(1.0, metadata.get(FEATURE_PREFIX + "holdout.weight")); + assertEquals(1.0, metadata.get(EVALUATION_FEATURE_PREFIX + "holdout.weight")); + assertEquals(true, metadata.get(EXPOSURE_FEATURE_PREFIX + "holdout.enabled")); + assertEquals(true, metadata.get(HAS_EVALUATION_FEATURES)); + } + + @Test + void marksEvaluationFeaturesOnlyWhenTheSplitHasSome() { + final Map metadata = + evaluate( + new FlagEvaluator(false, notExposed()), + false, + null, + singletonList(new Feature("holdout.key", "q4-global", singletonList("HOOK")))); + + assertFalse(metadata.containsKey(HAS_EVALUATION_FEATURES)); + } + + @Test + void keepsTheFirstFeatureOfARepeatedKeyAndSkipsIncompleteOnes() { + final Map metadata = + evaluate( + new FlagEvaluator(false, notExposed()), + false, + null, + asList( + null, + new Feature(null, "x", singletonList("HOOK")), + new Feature("no.destination", "x", null), + new Feature("holdout.key", "q4-global", singletonList("HOOK")), + new Feature("holdout.key", "q1-global", asList("HOOK", "EXPOSURE")), + new Feature("unsupported", singletonList("x"), singletonList("HOOK")))); + + assertEquals("q4-global", metadata.get(FEATURE_PREFIX + "holdout.key")); + assertFalse(metadata.containsKey(EXPOSURE_FEATURE_PREFIX + "holdout.key")); + assertFalse(metadata.containsKey(FEATURE_PREFIX + "no.destination")); + assertFalse(metadata.containsKey(FEATURE_PREFIX + "unsupported")); + } + + @Test + void warnsOnceForEachUnknownDestination() { + final FlagEvaluator evaluator = new FlagEvaluator(false, notExposed()); + final List features = + asList( + new Feature("holdout.key", "q4-global", asList("HOOK", "NEW_A")), + new Feature("other", "x", asList("NEW_A", "NEW_B", "EXPOSURE"))); + + evaluate(evaluator, true, 7, features); + final Map metadata = evaluate(evaluator, true, 7, features); + + assertEquals( + new HashSet<>(asList("NEW_A", "NEW_B")), FlagEvaluator.WARNED_FEATURE_DESTINATIONS); + assertEquals("q4-global", metadata.get(FEATURE_PREFIX + "holdout.key")); + assertEquals("x", metadata.get(EXPOSURE_FEATURE_PREFIX + "other")); + } + + @Test + void stopsRememberingUnknownDestinationsAtTheCap() { + for (int i = 0; i < FlagEvaluator.MAX_WARNED_FEATURE_DESTINATIONS; i++) { + FlagEvaluator.WARNED_FEATURE_DESTINATIONS.add("OLD_" + i); + } + + final Map metadata = + evaluate( + new FlagEvaluator(false, notExposed()), + true, + 7, + singletonList(new Feature("holdout.key", "q4-global", asList("HOOK", "NEW_A")))); + + assertEquals( + FlagEvaluator.MAX_WARNED_FEATURE_DESTINATIONS, + FlagEvaluator.WARNED_FEATURE_DESTINATIONS.size()); + assertFalse(FlagEvaluator.WARNED_FEATURE_DESTINATIONS.contains("NEW_A")); + assertEquals("q4-global", metadata.get(FEATURE_PREFIX + "holdout.key")); + } + + @Test + void addsNoFeaturesWhenTheSplitHasNone() { + final Map metadata = evaluate(new FlagEvaluator(false, notExposed()), "user-1"); + + for (final String key : metadata.keySet()) { + assertFalse(key.startsWith(FEATURE_PREFIX), key); + assertFalse(key.startsWith(EXPOSURE_FEATURE_PREFIX), key); + assertFalse(key.startsWith(EVALUATION_FEATURE_PREFIX), key); + } + assertNull(metadata.get(HAS_EVALUATION_FEATURES)); + assertTrue(metadata.containsKey(DO_LOG)); + } + + private static FlagEvaluator.ExposureLookup notExposed() { + return (flag, subject, allocation, variant, serialId) -> false; + } + + private static Map evaluate( + final FlagEvaluator evaluator, final String targetingKey) { + return evaluate(evaluator, true, 7, null, targetingKey); + } + + private static Map evaluate( + final FlagEvaluator evaluator, + final boolean doLog, + final Integer serialId, + final List features) { + return evaluate(evaluator, doLog, serialId, features, "user-1"); + } + + private static Map evaluate( + final FlagEvaluator evaluator, + final boolean doLog, + final Integer serialId, + final List features, + final String targetingKey) { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), serialId, features); + final Allocation allocation = + new Allocation("allocation", null, null, null, singletonList(split), doLog); + final Map flags = new HashMap<>(); + flags.put( + "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); + return evaluator + .evaluate( + new ServerConfiguration("", "", false, null, flags), + Integer.class, + "flag", + 0, + new MutableContext(targetingKey)) + .getFlagMetadata() + .asUnmodifiableMap(); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposureDeduplicationCacheTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposureDeduplicationCacheTest.java new file mode 100644 index 00000000000..6334e89b7d6 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposureDeduplicationCacheTest.java @@ -0,0 +1,146 @@ +package com.datadog.openfeature.internal.exposure; + +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 org.junit.jupiter.api.Test; +import org.tabletest.junit.TableTest; + +class ExposureDeduplicationCacheTest { + + @Test + void recordsANewExposure() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + + assertFalse(cache.contains("flag", "subject", "allocation", "variant", null)); + assertTrue(cache.recordIfChanged("flag", "subject", "allocation", "variant", null)); + + assertEquals(1, cache.size()); + assertTrue(cache.contains("flag", "subject", "allocation", "variant", null)); + } + + @Test + void doesNotRecordARepeatedExposureAsChanged() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + cache.recordIfChanged("flag", "subject", "allocation", "variant", null); + + assertFalse(cache.recordIfChanged("flag", "subject", "allocation", "variant", null)); + assertEquals(1, cache.size()); + } + + @Test + void replacesTheExposureOfASubjectWhenItChanges() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + cache.recordIfChanged("flag", "subject", "allocation1", "variant1", null); + + assertTrue(cache.recordIfChanged("flag", "subject", "allocation2", "variant2", null)); + + assertEquals(1, cache.size()); + assertTrue(cache.contains("flag", "subject", "allocation2", "variant2", null)); + assertFalse(cache.contains("flag", "subject", "allocation1", "variant1", null)); + } + + @TableTest({ + "scenario | first | second | changed", + "serial id appears | | 7 | true ", + "serial id vanishes | 7 | | true ", + "serial id changes | 7 | 8 | true ", + "serial id unchanged | 7 | 7 | false ", + "both absent | | | false ", + "zero then absent | 0 | | true ", + "absent then zero | | 0 | true " + }) + void comparesTheSerialId(final Integer first, final Integer second, final boolean changed) { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + cache.recordIfChanged("flag", "subject", "allocation", "variant", first); + + assertEquals( + changed, cache.recordIfChanged("flag", "subject", "allocation", "variant", second)); + } + + @Test + void sendsAnExposurePerSerialIdTransition() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + + assertTrue(cache.recordIfChanged("flag", "subject", "allocation", "variant", 7)); + assertTrue(cache.recordIfChanged("flag", "subject", "allocation", "variant", 8)); + assertTrue(cache.recordIfChanged("flag", "subject", "allocation", "variant", 7)); + } + + @Test + void keysBySubjectAndFlag() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(10); + for (int index = 0; index < 5; index++) { + assertTrue(cache.recordIfChanged("flag", "subject" + index, "allocation", "variant", null)); + assertTrue(cache.recordIfChanged("flag" + index, "subject", "allocation", "variant", null)); + } + + assertEquals(10, cache.size()); + } + + @Test + void acceptsNullKeys() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + cache.recordIfChanged(null, null, "allocation", "variant", null); + + assertFalse(cache.recordIfChanged(null, null, "allocation", "variant", null)); + assertTrue(cache.contains(null, null, "allocation", "variant", null)); + assertEquals(1, cache.size()); + } + + @Test + void evictsTheLeastRecentlyUsedSubject() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(2); + cache.recordIfChanged("flag", "user-1", "allocation", "on", 7); + cache.recordIfChanged("flag", "user-2", "allocation", "on", 7); + assertTrue(cache.contains("flag", "user-1", "allocation", "on", 7)); + cache.recordIfChanged("flag", "user-3", "allocation", "on", 7); + + assertEquals(2, cache.size()); + assertTrue(cache.contains("flag", "user-1", "allocation", "on", 7)); + assertFalse(cache.contains("flag", "user-2", "allocation", "on", 7)); + assertTrue(cache.contains("flag", "user-3", "allocation", "on", 7)); + assertFalse(cache.contains("flag", "user-1", "allocation", "on", 8)); + } + + @Test + void updatingASubjectKeepsItHot() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(3); + cache.recordIfChanged("flag1", "subject1", "allocation1", "variant1", null); + cache.recordIfChanged("flag2", "subject2", "allocation2", "variant2", null); + cache.recordIfChanged("flag3", "subject3", "allocation3", "variant3", null); + cache.recordIfChanged("flag1", "subject1", "allocation2", "variant2", null); + cache.recordIfChanged("flag4", "subject4", "allocation4", "variant4", null); + + assertEquals(3, cache.size()); + assertTrue(cache.contains("flag1", "subject1", "allocation2", "variant2", null)); + assertFalse(cache.contains("flag2", "subject2", "allocation2", "variant2", null)); + assertTrue(cache.contains("flag4", "subject4", "allocation4", "variant4", null)); + } + + @Test + void boundsItsSize() { + final ExposureDeduplicationCache single = new ExposureDeduplicationCache(1); + single.recordIfChanged("flag1", "subject1", "allocation", "variant", null); + single.recordIfChanged("flag2", "subject2", "allocation", "variant", null); + final ExposureDeduplicationCache none = new ExposureDeduplicationCache(0); + + assertTrue(none.recordIfChanged("flag", "subject", "allocation", "variant", null)); + assertEquals(1, single.size()); + assertEquals(0, none.size()); + assertEquals(1 << 16, ExposureDeduplicationCache.CAPACITY); + assertEquals(0, new ExposureDeduplicationCache().size()); + } + + @Test + void clearForgetsEveryExposure() { + final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(5); + cache.recordIfChanged("flag", "subject", "allocation", "variant", null); + + cache.clear(); + + assertEquals(0, cache.size()); + assertFalse(cache.contains("flag", "subject", "allocation", "variant", null)); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java index 3595fdc6dc4..bbc5b4339f0 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java @@ -105,30 +105,18 @@ void serializesExposureWireShape() throws Exception { } @Test - void testLruCache() throws Exception { - final List exposures = buildExposures(6); + void sendsEveryExposureAsTheProviderDeduplicatesThem() throws Exception { + final List exposures = buildExposures(3); try (ExposurePipeline pipeline = pipeline(emptyMap())) { pipeline.start(); - // populating the cache exposures.forEach(pipeline); - - // all events are written - eventually(() -> allExposures().size() == exposures.size()); - - // publishing duplicate events exposures.forEach(pipeline); - // no events are written - MILLISECONDS.sleep(300); // wait until a flush happens - assertEquals(exposures.size(), allExposures().size()); - - // a new event is generated - pipeline.accept(buildExposure()); - - // oldest event is evicted and the new one is submitted - eventually(() -> allExposures().size() == exposures.size() + 1); + eventually(() -> allExposures().size() == 2 * exposures.size()); } + + assertEquals(keys(exposures), exposureKeys(allExposures())); } @Test diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java deleted file mode 100644 index f48c3e32e0a..00000000000 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/LRUExposureCacheTest.java +++ /dev/null @@ -1,308 +0,0 @@ -package com.datadog.openfeature.internal.exposure; - -import static java.util.Collections.emptyMap; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.ArrayList; -import java.util.List; -import org.junit.jupiter.api.Test; - -class LRUExposureCacheTest { - - @Test - void testAddingElements() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - boolean added = cache.add(event); - - assertTrue(added); - assertEquals(1, cache.size()); - } - - @Test - void testAddingDuplicateEventsReturnsFalse() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - cache.add(event); - boolean duplicateAdded = cache.add(event); - - assertFalse(duplicateAdded); - assertEquals(1, cache.size()); - } - - @Test - void testAddingEventsWithSameKeyButDifferentDetailsUpdatesCache() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event1 = createEvent("flag", "subject", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag", "subject", "variant2", "allocation2"); - ExposureCache.Key key = new ExposureCache.Key(event1); - - boolean added1 = cache.add(event1); - boolean added2 = cache.add(event2); - ExposureCache.Value retrieved = cache.get(key); - - assertTrue(added1); - assertTrue(added2); - assertEquals(1, cache.size()); - assertEquals("variant2", retrieved.variant); - assertEquals("allocation2", retrieved.allocation); - } - - @Test - void testSerialIdAppearingEmitsANewExposure() { - assertEquals(2, emissions(null, 7)); - } - - @Test - void testSerialIdDisappearingEmitsANewExposure() { - assertEquals(2, emissions(7, null)); - } - - @Test - void testSerialIdChangingEmitsANewExposure() { - assertEquals(2, emissions(7, 8)); - } - - @Test - void testSerialIdCycleEmitsAnExposurePerTransition() { - assertEquals(3, emissions(7, 8, 7)); - } - - @Test - void testUnchangedSerialIdIsDeduplicated() { - assertEquals(1, emissions(7, 7)); - } - - @Test - void testUnchangedAbsentSerialIdIsDeduplicated() { - assertEquals(1, emissions(null, null)); - } - - @Test - void testSerialIdZeroIsDistinguishedFromAbsent() { - assertEquals(2, emissions(null, 0)); - assertEquals(2, emissions(0, null)); - } - - @Test - void testSerialIdIsRetainedOnTheCachedValue() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation", 0); - - cache.add(event); - - assertEquals(Integer.valueOf(0), cache.get(new ExposureCache.Key(event)).serialId); - } - - @Test - void testLruEvictionWhenCapacityExceeded() { - LRUExposureCache cache = new LRUExposureCache(2); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key3 = new ExposureCache.Key(event3); - - cache.add(event1); - cache.add(event2); - cache.add(event3); - - assertEquals(2, cache.size()); - assertNull(cache.get(key1)); // event1 should be evicted - assertNotNull(cache.get(key3)); // event3 should be present - assertEquals("variant3", cache.get(key3).variant); - assertEquals("allocation3", cache.get(key3).allocation); - } - - @Test - void testSingleCapacityCache() { - LRUExposureCache cache = new LRUExposureCache(1); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - - cache.add(event1); - cache.add(event2); - - assertEquals(1, cache.size()); - } - - @Test - void testZeroCapacityCache() { - LRUExposureCache cache = new LRUExposureCache(0); - ExposureEvent event = createEvent("flag", "subject", "variant", "allocation"); - - boolean added = cache.add(event); - - assertTrue(added); - assertEquals(0, cache.size()); - } - - @Test - void testEmptyCacheSize() { - LRUExposureCache cache = new LRUExposureCache(5); - - assertEquals(0, cache.size()); - } - - @Test - void testMultipleAdditionsWithSameFlagDifferentSubjects() { - LRUExposureCache cache = new LRUExposureCache(10); - List events = new ArrayList<>(); - for (int index = 0; index < 5; index++) { - events.add(createEvent("flag", "subject" + index, "variant", "allocation")); - } - - for (ExposureEvent event : events) { - assertTrue(cache.add(event)); - } - - assertEquals(5, cache.size()); - } - - @Test - void testMultipleAdditionsWithSameSubjectDifferentFlags() { - LRUExposureCache cache = new LRUExposureCache(10); - List events = new ArrayList<>(); - for (int index = 0; index < 5; index++) { - events.add(createEvent("flag" + index, "subject", "variant", "allocation")); - } - - for (ExposureEvent event : events) { - assertTrue(cache.add(event)); - } - - assertEquals(5, cache.size()); - } - - @Test - void testKeyEqualityWithNullValues() { - LRUExposureCache cache = new LRUExposureCache(5); - ExposureEvent event1 = - new ExposureEvent( - System.currentTimeMillis(), - new Allocation("allocation"), - new Flag(null), - new Variant("variant"), - new Subject(null, emptyMap()), - null); - ExposureEvent event2 = - new ExposureEvent( - System.currentTimeMillis(), - new Allocation("allocation"), - new Flag(null), - new Variant("variant"), - new Subject(null, emptyMap()), - null); - - cache.add(event1); - boolean duplicateAdded = cache.add(event2); - - assertFalse(duplicateAdded); - assertEquals(1, cache.size()); - } - - @Test - void testUpdatingExistingKeyMaintainsLruPosition() { - LRUExposureCache cache = new LRUExposureCache(3); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - ExposureEvent event1Updated = createEvent("flag1", "subject1", "variant2", "allocation2"); - ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key2 = new ExposureCache.Key(event2); - ExposureCache.Key key4 = new ExposureCache.Key(event4); - - cache.add(event1); - cache.add(event2); - cache.add(event3); - cache.add(event1Updated); // Updates event1, moves to most recent - cache.add(event4); // Should evict event2, not event1 - - assertEquals(3, cache.size()); - assertNotNull(cache.get(key1)); // event1 should be updated and present - assertEquals("variant2", cache.get(key1).variant); // verify it was updated - assertEquals("allocation2", cache.get(key1).allocation); - assertNull(cache.get(key2)); // event2 should be evicted - assertNotNull(cache.get(key4)); // event4 should be present - assertEquals("variant4", cache.get(key4).variant); - } - - @Test - void testDuplicateExposureKeepsSubjectHotInLruOrder() { - LRUExposureCache cache = new LRUExposureCache(3); - ExposureEvent event1 = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event2 = createEvent("flag2", "subject2", "variant2", "allocation2"); - ExposureEvent event3 = createEvent("flag3", "subject3", "variant3", "allocation3"); - // same key + same details as event1: will go through the "duplicate" path - ExposureEvent event1Duplicate = createEvent("flag1", "subject1", "variant1", "allocation1"); - ExposureEvent event4 = createEvent("flag4", "subject4", "variant4", "allocation4"); - - ExposureCache.Key key1 = new ExposureCache.Key(event1); - ExposureCache.Key key2 = new ExposureCache.Key(event2); - ExposureCache.Key key4 = new ExposureCache.Key(event4); - - // Fill cache - boolean added1 = cache.add(event1); - boolean added2 = cache.add(event2); - boolean added3 = cache.add(event3); - - // Duplicate exposure for subject1: should *not* change size, but *should* bump recency - boolean duplicateAdded = cache.add(event1Duplicate); - - // Now push over capacity: the least recently used *non-hot* entry (event2) should be evicted - boolean added4 = cache.add(event4); - - assertTrue(added1); - assertTrue(added2); - assertTrue(added3); - assertFalse(duplicateAdded); // dedup correctly - assertTrue(added4); - - assertEquals(3, cache.size()); - - assertNotNull(cache.get(key1)); // hot subject1 should still be present - assertNull(cache.get(key2)); // subject2 should be evicted - assertNotNull(cache.get(key4)); // newest subject4 should be present - - assertEquals("variant1", cache.get(key1).variant); - assertEquals("allocation1", cache.get(key1).allocation); - } - - private static ExposureEvent createEvent( - String flag, String subject, String variant, String allocation) { - return createEvent(flag, subject, variant, allocation, null); - } - - private static ExposureEvent createEvent( - String flag, String subject, String variant, String allocation, Integer serialId) { - return new ExposureEvent( - System.currentTimeMillis(), - new Allocation(allocation), - new Flag(flag), - new Variant(variant), - new Subject(subject, emptyMap()), - serialId); - } - - /** - * Number of exposures a run of same-flag, same-subject evaluations emits: the cache reports true - * only for the ones it does not suppress as duplicates. - */ - private static int emissions(final Integer... serialIds) { - LRUExposureCache cache = new LRUExposureCache(5); - int emitted = 0; - for (Integer serialId : serialIds) { - if (cache.add(createEvent("flag", "subject", "variant", "allocation", serialId))) { - emitted++; - } - } - return emitted; - } -} From 40aeacb46269970249690fc746fa3a645ae370f2 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 08:38:43 +0200 Subject: [PATCH 45/50] refactor(ffe): Rename the SDK connector to agent bridge Rename the internal.connector SPI package to internal.bridge, Connector to AgentBridge, NoConnector to NoopAgentBridge and Connectors to AgentBridges. The detect() entry point stays out of AgentBridge: the agent injects the bridge implementations when instrumenting it, and they cannot be defined while their own interface is being defined. --- .../openfeature/internal/RuntimeServices.java | 2 +- .../AgentBridge.java} | 8 +++--- .../internal/bridge/AgentBridges.java | 20 +++++++++++++ .../ConfigurationSource.java | 2 +- .../EventProxyUnavailableException.java | 2 +- .../{connector => bridge}/EventTransport.java | 2 +- .../{connector => bridge}/HealthMetrics.java | 2 +- .../NoopAgentBridge.java} | 4 +-- .../{connector => bridge}/SpanEnricher.java | 2 +- .../{connector => bridge}/package-info.java | 2 +- .../openfeature/internal/config/Settings.java | 25 +++++++++-------- .../internal/connector/Connectors.java | 16 ----------- .../delivery/DirectIntakeTransport.java | 2 +- .../internal/delivery/FallbackTransport.java | 4 +-- .../delivery/RemoteConfigurationService.java | 2 +- .../internal/config/SettingsTest.java | 28 +++++++++---------- .../internal/config/TestSettings.java | 12 ++++---- .../delivery/CdnConfigurationSourceTest.java | 2 +- .../delivery/FallbackTransportTest.java | 4 +-- 19 files changed, 73 insertions(+), 68 deletions(-) rename dd-openfeature/src/main/java/com/datadog/openfeature/internal/{connector/Connector.java => bridge/AgentBridge.java} (83%) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/AgentBridges.java rename dd-openfeature/src/main/java/com/datadog/openfeature/internal/{connector => bridge}/ConfigurationSource.java (89%) rename dd-openfeature/src/main/java/com/datadog/openfeature/internal/{connector => bridge}/EventProxyUnavailableException.java (87%) rename dd-openfeature/src/main/java/com/datadog/openfeature/internal/{connector => bridge}/EventTransport.java (89%) rename dd-openfeature/src/main/java/com/datadog/openfeature/internal/{connector => bridge}/HealthMetrics.java (89%) rename dd-openfeature/src/main/java/com/datadog/openfeature/internal/{connector/NoConnector.java => bridge/NoopAgentBridge.java} (82%) rename dd-openfeature/src/main/java/com/datadog/openfeature/internal/{connector => bridge}/SpanEnricher.java (95%) rename dd-openfeature/src/main/java/com/datadog/openfeature/internal/{connector => bridge}/package-info.java (85%) delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java index 49cd87d9dcc..8227e19d851 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/RuntimeServices.java @@ -1,6 +1,6 @@ package com.datadog.openfeature.internal; -import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.bridge.HealthMetrics; import javax.annotation.Nullable; /** Thread creation and health metrics shared by the runtime components. */ diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/AgentBridge.java similarity index 83% rename from dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/AgentBridge.java index d67c36f9fb7..1ff84479cec 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connector.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/AgentBridge.java @@ -1,11 +1,11 @@ -package com.datadog.openfeature.internal.connector; +package com.datadog.openfeature.internal.bridge; import javax.annotation.Nullable; /** Components supplied by the Datadog Java agent when attached, or no-op ones otherwise. */ -public interface Connector { - /** The connector used when no Datadog Java agent is attached. */ - Connector NONE = new NoConnector(); +public interface AgentBridge { + /** The bridge used when no Datadog Java agent is attached. */ + AgentBridge NONE = new NoopAgentBridge(); /** * Looks up a setting from the agent configuration. diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/AgentBridges.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/AgentBridges.java new file mode 100644 index 00000000000..f11022880ae --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/AgentBridges.java @@ -0,0 +1,20 @@ +package com.datadog.openfeature.internal.bridge; + +/** + * Resolves the {@link AgentBridge} to use. Kept apart from {@link AgentBridge}: the agent injects + * bridge implementations when instrumenting {@link #detect()}, and they cannot be defined while + * their {@link AgentBridge} interface is itself being defined. + */ +public final class AgentBridges { + private AgentBridges() {} + + /** + * Detects the bridge to use. The Datadog Java agent instruments this method to return its own + * bridge. + * + * @return the bridge to use. + */ + public static AgentBridge detect() { + return AgentBridge.NONE; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/ConfigurationSource.java similarity index 89% rename from dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/ConfigurationSource.java index 21307031fe7..263938ee973 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/ConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/ConfigurationSource.java @@ -1,4 +1,4 @@ -package com.datadog.openfeature.internal.connector; +package com.datadog.openfeature.internal.bridge; import java.util.function.Consumer; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/EventProxyUnavailableException.java similarity index 87% rename from dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/EventProxyUnavailableException.java index c85887214fd..b141a2fce00 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventProxyUnavailableException.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/EventProxyUnavailableException.java @@ -1,4 +1,4 @@ -package com.datadog.openfeature.internal.connector; +package com.datadog.openfeature.internal.bridge; import java.io.IOException; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/EventTransport.java similarity index 89% rename from dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/EventTransport.java index 0bdfb355590..6f2eb3aca6e 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/EventTransport.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/EventTransport.java @@ -1,4 +1,4 @@ -package com.datadog.openfeature.internal.connector; +package com.datadog.openfeature.internal.bridge; import java.io.IOException; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/HealthMetrics.java similarity index 89% rename from dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/HealthMetrics.java index fcb0e99f558..2b1c247cb91 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/HealthMetrics.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/HealthMetrics.java @@ -1,4 +1,4 @@ -package com.datadog.openfeature.internal.connector; +package com.datadog.openfeature.internal.bridge; import javax.annotation.Nullable; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/NoopAgentBridge.java similarity index 82% rename from dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/NoopAgentBridge.java index 1f48b8b05ff..dab3e42db97 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/NoConnector.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/NoopAgentBridge.java @@ -1,8 +1,8 @@ -package com.datadog.openfeature.internal.connector; +package com.datadog.openfeature.internal.bridge; import javax.annotation.Nullable; -final class NoConnector implements Connector { +final class NoopAgentBridge implements AgentBridge { @Nullable @Override public String setting(final String key) { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/SpanEnricher.java similarity index 95% rename from dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/SpanEnricher.java index 2d744b891f2..768481bb6e8 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/SpanEnricher.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/SpanEnricher.java @@ -1,4 +1,4 @@ -package com.datadog.openfeature.internal.connector; +package com.datadog.openfeature.internal.bridge; import javax.annotation.Nullable; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/package-info.java similarity index 85% rename from dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java rename to dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/package-info.java index 98469feaeb0..f4d9dead3c4 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/package-info.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/bridge/package-info.java @@ -5,4 +5,4 @@ * instrumentation, which can run a different version than the SDK, so they must evolve in a * backward compatible way. */ -package com.datadog.openfeature.internal.connector; +package com.datadog.openfeature.internal.bridge; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index 8fb75928cf4..02893fce0c9 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -1,6 +1,6 @@ package com.datadog.openfeature.internal.config; -import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.bridge.AgentBridge; import de.thetaphi.forbiddenapis.SuppressForbidden; import java.util.Locale; import java.util.function.UnaryOperator; @@ -9,13 +9,14 @@ import org.slf4j.LoggerFactory; /** - * Resolves the SDK settings. A setting is looked up from the {@link Connector} first, then from the - * {@code dd.}-prefixed system property, then from the {@code DD_}-prefixed environment variable. + * Resolves the SDK settings. A setting is looked up from the {@link AgentBridge} first, then from + * the {@code dd.}-prefixed system property, then from the {@code DD_}-prefixed environment + * variable. */ public final class Settings { private static final Logger LOGGER = LoggerFactory.getLogger(Settings.class); - /** The setting sources: connector, system properties, then environment variables. */ + /** The setting sources: bridge, system properties, then environment variables. */ private static final int SOURCE_COUNT = 3; public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; @@ -45,30 +46,30 @@ public final class Settings { static final int DEFAULT_POLL_INTERVAL_SECONDS = 30; static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 5; - private final Connector connector; + private final AgentBridge bridge; private final UnaryOperator systemProperties; private final UnaryOperator environment; Settings( - final Connector connector, + final AgentBridge bridge, final UnaryOperator systemProperties, final UnaryOperator environment) { - this.connector = connector; + this.bridge = bridge; this.systemProperties = systemProperties; this.environment = environment; } /** - * Creates the settings backed by the given connector, system properties and environment. + * Creates the settings backed by the given bridge, system properties and environment. * - * @param connector the connector to look settings up from first. + * @param bridge the bridge to look settings up from first. * @return the settings. */ // Allowing VM properties and environment variable access // as the SDK does not ship the agent configuration helpers @SuppressForbidden - public static Settings load(final Connector connector) { - return new Settings(connector, System::getProperty, System::getenv); + public static Settings load(final AgentBridge bridge) { + return new Settings(bridge, System::getProperty, System::getenv); } /** @@ -224,7 +225,7 @@ String get(final String key) { private String value(final String key, final int source) { switch (source) { case 0: - return this.connector.setting(key); + return this.bridge.setting(key); case 1: return this.systemProperties.apply("dd." + key); default: diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java deleted file mode 100644 index cf9be77a214..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/connector/Connectors.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.datadog.openfeature.internal.connector; - -/** Resolves the {@link Connector} to use. */ -public final class Connectors { - private Connectors() {} - - /** - * Detects the connector to use. The Datadog Java agent instruments this method to return its own - * connector. - * - * @return the connector to use. - */ - public static Connector detect() { - return Connector.NONE; - } -} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java index 84e60ff61bc..9c004fd54c6 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/DirectIntakeTransport.java @@ -3,8 +3,8 @@ import static java.net.http.HttpClient.Redirect.NEVER; import static java.net.http.HttpRequest.BodyPublishers.ofByteArray; +import com.datadog.openfeature.internal.bridge.EventTransport; import com.datadog.openfeature.internal.config.Settings; -import com.datadog.openfeature.internal.connector.EventTransport; import java.io.IOException; import java.net.ConnectException; import java.net.URI; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java index fe26bb6d31f..e01bf019672 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/FallbackTransport.java @@ -1,7 +1,7 @@ package com.datadog.openfeature.internal.delivery; -import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; -import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.bridge.EventProxyUnavailableException; +import com.datadog.openfeature.internal.bridge.EventTransport; import java.io.IOException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java index 8ed5f9ce4f1..8d1a4c6434a 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java @@ -1,7 +1,7 @@ package com.datadog.openfeature.internal.delivery; import com.datadog.openfeature.internal.ConfigurationService; -import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; import java.io.IOException; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java index 170cbd2a8e8..1cfd0a827c5 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -5,11 +5,11 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.datadog.openfeature.internal.connector.ConfigurationSource; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.EventTransport; -import com.datadog.openfeature.internal.connector.HealthMetrics; -import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.EventTransport; +import com.datadog.openfeature.internal.bridge.HealthMetrics; +import com.datadog.openfeature.internal.bridge.SpanEnricher; import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; @@ -17,19 +17,19 @@ class SettingsTest { @Test - void looksUpConnectorThenSystemPropertiesThenEnvironment() { - final Map connector = new HashMap<>(); + void looksUpBridgeThenSystemPropertiesThenEnvironment() { + final Map bridge = new HashMap<>(); final Map properties = new HashMap<>(); final Map environment = new HashMap<>(); final Settings settings = - new Settings(new SettingConnector(connector), properties::get, environment::get); + new Settings(new SettingAgentBridge(bridge), properties::get, environment::get); assertNull(settings.env()); environment.put("DD_ENV", "from-env"); assertEquals("from-env", settings.env()); properties.put("dd.env", "from-property"); assertEquals("from-property", settings.env()); - connector.put("env", "from-agent"); + bridge.put("env", "from-agent"); assertEquals("from-agent", settings.env()); } @@ -68,7 +68,7 @@ void resolvesConfigurationSource( putIfNotNull(properties, "dd.feature.flags.enabled", enabled); putIfNotNull(properties, "dd.feature.flags.configuration.source", source); putIfNotNull(properties, "dd.experimental.flagging.provider.enabled", legacy); - final Settings settings = new Settings(Connector.NONE, properties::get, name -> null); + final Settings settings = new Settings(AgentBridge.NONE, properties::get, name -> null); assertEquals(resolved, settings.configurationSource()); assertEquals(resolved != null, settings.isEnabled()); @@ -130,7 +130,7 @@ void fallsBackToLowerPrioritySourcesOnBlankOrInvalidValues() { final Map environment = new HashMap<>(); environment.put("DD_FLAGGING_EVALUATION_COUNTS_ENABLED", "false"); environment.put("DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS", "10"); - final Settings settings = new Settings(Connector.NONE, properties::get, environment::get); + final Settings settings = new Settings(AgentBridge.NONE, properties::get, environment::get); assertFalse(settings.isEvaluationCountsEnabled()); assertEquals(10, settings.agentlessPollIntervalSeconds()); @@ -167,11 +167,11 @@ private static void putIfNotNull( } } - /** A connector only providing settings. */ - private static final class SettingConnector implements Connector { + /** A bridge only providing settings. */ + private static final class SettingAgentBridge implements AgentBridge { private final Map settings; - SettingConnector(final Map settings) { + SettingAgentBridge(final Map settings) { this.settings = settings; } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java index 32caeb81da1..6acc0792138 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/TestSettings.java @@ -1,6 +1,6 @@ package com.datadog.openfeature.internal.config; -import com.datadog.openfeature.internal.connector.Connector; +import com.datadog.openfeature.internal.bridge.AgentBridge; import java.util.HashMap; import java.util.Map; @@ -13,19 +13,19 @@ private TestSettings() {} * @return the settings backed by the given values only. */ public static Settings of(final String... keyValues) { - return of(Connector.NONE, keyValues); + return of(AgentBridge.NONE, keyValues); } /** - * @param connector the connector to look settings up from first. + * @param bridge the bridge to look settings up from first. * @param keyValues alternating setting keys, without the {@code dd.} prefix, and values. - * @return the settings backed by the connector and the given values only. + * @return the settings backed by the bridge and the given values only. */ - public static Settings of(final Connector connector, final String... keyValues) { + public static Settings of(final AgentBridge bridge, final String... keyValues) { final Map properties = new HashMap<>(); for (int i = 0; i < keyValues.length; i += 2) { properties.put("dd." + keyValues[i], keyValues[i + 1]); } - return new Settings(connector, properties::get, name -> null); + return new Settings(bridge, properties::get, name -> null); } } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java index 838ef1e4ae6..7ddcbaba216 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSourceTest.java @@ -17,9 +17,9 @@ import com.datadog.openfeature.internal.LocalHttpServer; import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.bridge.HealthMetrics; import com.datadog.openfeature.internal.config.Settings; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.HealthMetrics; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import java.io.IOException; import java.net.InetAddress; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java index e7b55abb572..ce05e06d685 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/FallbackTransportTest.java @@ -8,8 +8,8 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; -import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.bridge.EventProxyUnavailableException; +import com.datadog.openfeature.internal.bridge.EventTransport; import java.io.IOException; import org.junit.jupiter.api.Test; From 9c686e6f90e85e8a9736f6c110a761b9fd8a2773 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 08:38:45 +0200 Subject: [PATCH 46/50] refactor(ffe): Use the agent bridge in the event pipelines --- .../FlagEvaluationEnqueueContentionBenchmark.java | 2 +- .../flagevaluation/FlagEvaluationHotPathBenchmark.java | 4 ++-- .../openfeature/internal/exposure/ExposurePipeline.java | 2 +- .../internal/flagevaluation/FlagEvaluationPipeline.java | 2 +- .../openfeature/internal/exposure/ExposurePipelineTest.java | 4 ++-- .../internal/flagevaluation/FlagEvaluationTestSupport.java | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java index b862c1388f9..7ca26289a3e 100644 --- a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationEnqueueContentionBenchmark.java @@ -4,7 +4,7 @@ import static java.util.concurrent.TimeUnit.SECONDS; import com.datadog.openfeature.internal.RuntimeServices; -import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.bridge.HealthMetrics; import de.thetaphi.forbiddenapis.SuppressForbidden; import java.util.HashMap; import java.util.Map; diff --git a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java index 6a1f6154b2f..d5f756d6d1a 100644 --- a/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java +++ b/dd-openfeature/src/jmh/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationHotPathBenchmark.java @@ -4,8 +4,8 @@ import static java.util.concurrent.TimeUnit.SECONDS; import com.datadog.openfeature.internal.RuntimeServices; -import com.datadog.openfeature.internal.connector.EventTransport; -import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.bridge.EventTransport; +import com.datadog.openfeature.internal.bridge.HealthMetrics; import java.util.HashMap; import java.util.Map; import org.openjdk.jmh.annotations.Benchmark; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java index 78199967b50..f0a43ea866a 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java @@ -2,7 +2,7 @@ import com.datadog.openfeature.internal.EventQueue; import com.datadog.openfeature.internal.RuntimeServices; -import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.bridge.EventTransport; import java.util.ArrayList; import java.util.List; import java.util.Map; diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java index 8888f6331ba..d2f3bd924fd 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java @@ -2,7 +2,7 @@ import com.datadog.openfeature.internal.EventQueue; import com.datadog.openfeature.internal.RuntimeServices; -import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.bridge.EventTransport; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.List; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java index bbc5b4339f0..c40ee085121 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineTest.java @@ -10,8 +10,8 @@ import com.datadog.openfeature.internal.JsonReading; import com.datadog.openfeature.internal.RuntimeServices; -import com.datadog.openfeature.internal.connector.EventTransport; -import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.bridge.EventTransport; +import com.datadog.openfeature.internal.bridge.HealthMetrics; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java index 7792035d2d2..1dd1e51d911 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationTestSupport.java @@ -7,8 +7,8 @@ import com.datadog.openfeature.internal.JsonReading; import com.datadog.openfeature.internal.RuntimeServices; -import com.datadog.openfeature.internal.connector.EventTransport; -import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.bridge.EventTransport; +import com.datadog.openfeature.internal.bridge.HealthMetrics; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; From f82827442749554ab877dd6e3777cfbe8024e69e Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 08:38:45 +0200 Subject: [PATCH 47/50] refactor(ffe): Use the agent bridge in the provider --- .../com/datadog/openfeature/DDEvaluator.java | 10 ++-- .../com/datadog/openfeature/Provider.java | 14 +++--- .../openfeature/SpanEnrichmentHook.java | 4 +- .../internal/FeatureFlagsRuntime.java | 22 ++++----- .../openfeature/CanonicalFixtureTest.java | 4 +- .../DDEvaluatorSpanEnrichmentTest.java | 4 +- .../datadog/openfeature/DDEvaluatorTest.java | 8 ++-- .../DatadogExposureLoggingTest.java | 18 +++---- .../openfeature/ExposureLoggingHookTest.java | 5 +- .../openfeature/ExposureTestEvaluator.java | 4 +- .../com/datadog/openfeature/ProviderTest.java | 28 +++++------ .../openfeature/SpanEnrichmentHookTest.java | 2 +- .../internal/FeatureFlagsRuntimeTest.java | 48 ++++++++++--------- 13 files changed, 87 insertions(+), 84 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java index 04b8b840c55..d5d9c1866db 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DDEvaluator.java @@ -3,8 +3,8 @@ import static java.util.Arrays.asList; import com.datadog.openfeature.internal.FeatureFlagsRuntime; +import com.datadog.openfeature.internal.bridge.AgentBridge; import com.datadog.openfeature.internal.config.Settings; -import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.evaluation.ConfigurationStore; import com.datadog.openfeature.internal.evaluation.FlagEvaluator; import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; @@ -83,15 +83,15 @@ class DDEvaluator implements Evaluator, Consumer { static final int MAX_STRUCTURE_PROPERTIES = 256; private final Runnable configCallback; - private final Connector connector; + private final AgentBridge bridge; private final Settings settings; private final FlagEvaluator core; private final ConfigurationStore configuration = new ConfigurationStore(); private volatile FeatureFlagsRuntime runtime; - DDEvaluator(final Runnable configCallback, final Connector connector, final Settings settings) { + DDEvaluator(final Runnable configCallback, final AgentBridge bridge, final Settings settings) { this.configCallback = configCallback; - this.connector = connector; + this.bridge = bridge; this.settings = settings; // When span enrichment is off, the __dd_* span-enrichment metadata is attached only to // evaluations of allocations logging exposures, for the exposure hook. @@ -104,7 +104,7 @@ public synchronized boolean initialize( if (this.runtime != null) { return hasConfiguration(); } - final FeatureFlagsRuntime acquired = FeatureFlagsRuntime.acquire(this.connector, this.settings); + final FeatureFlagsRuntime acquired = FeatureFlagsRuntime.acquire(this.bridge, this.settings); this.runtime = acquired; acquired.addConfigurationListener(this); return this.configuration.await(timeout, unit) || hasConfiguration(); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java index 3d672ef1e3a..cc421c310c4 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -3,9 +3,9 @@ import static java.util.concurrent.TimeUnit.SECONDS; import com.datadog.openfeature.internal.FeatureFlagsRuntime; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.AgentBridges; import com.datadog.openfeature.internal.config.Settings; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.Connectors; import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationWriter; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; @@ -36,7 +36,7 @@ public class Provider extends EventProvider implements Metadata { private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); private volatile Evaluator evaluator; private final Options options; - private final Connector connector; + private final AgentBridge bridge; private final Settings settings; private final AtomicReference initializationState = new AtomicReference<>(InitializationState.NOT_STARTED); @@ -76,8 +76,8 @@ public Provider(final Options options) { final Boolean spanEnrichmentEnabledOverride) { this.options = options; this.evaluator = evaluator; - this.connector = Connectors.detect(); - this.settings = Settings.load(this.connector); + this.bridge = AgentBridges.detect(); + this.settings = Settings.load(this.bridge); FlagEvalMetrics metrics = null; FlagEvalMetricsHook hook = null; try { @@ -97,7 +97,7 @@ public Provider(final Options options) { ? spanEnrichmentEnabledOverride : this.settings.isSpanEnrichmentEnabled(); this.spanEnrichmentHook = - spanEnrichmentEnabled ? new SpanEnrichmentHook(this.connector.spanEnricher()) : null; + spanEnrichmentEnabled ? new SpanEnrichmentHook(this.bridge.spanEnricher()) : null; // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) // allocates nothing, including when the gate is off. @@ -227,7 +227,7 @@ private Evaluator buildEvaluator() { if (evaluator != null) { return evaluator; } - return new DDEvaluator(this::onConfigurationChange, this.connector, this.settings); + return new DDEvaluator(this::onConfigurationChange, this.bridge, this.settings); } private FlagEvaluationWriter flagEvaluationPipeline() { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java index 0d4ec8af05f..c02f3705c8c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/SpanEnrichmentHook.java @@ -1,6 +1,6 @@ package com.datadog.openfeature; -import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.bridge.SpanEnricher; import com.datadog.openfeature.internal.evaluation.MetadataKeys; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; @@ -69,7 +69,7 @@ public void finallyAfter( this.enricher.serialId((Integer) serialValue, 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 connector carries only JDK types. + // Value to a native Java type here so the bridge carries only JDK types. this.enricher.runtimeDefault(details.getFlagKey(), unwrapDefaultValue(details.getValue())); } } catch (final Throwable t) { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java index fedcc1a41d0..89a9b0fd4b1 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -2,10 +2,10 @@ import static com.datadog.openfeature.internal.config.Settings.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.EventTransport; import com.datadog.openfeature.internal.config.Settings; -import com.datadog.openfeature.internal.connector.ConfigurationSource; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.EventTransport; import com.datadog.openfeature.internal.delivery.CdnConfigurationSource; import com.datadog.openfeature.internal.delivery.DirectIntakeTransport; import com.datadog.openfeature.internal.delivery.FallbackTransport; @@ -56,15 +56,15 @@ public final class FeatureFlagsRuntime { * Acquires the shared runtime, starting it on first acquisition. Each acquisition must be paired * with a {@link #release()}. * - * @param connector the connector to the Datadog Java agent. + * @param bridge the bridge to the Datadog Java agent. * @param settings the SDK settings. * @return the shared runtime. * @throws IllegalStateException if Feature Flags is disabled or misconfigured. */ - public static FeatureFlagsRuntime acquire(final Connector connector, final Settings settings) { + public static FeatureFlagsRuntime acquire(final AgentBridge bridge, final Settings settings) { synchronized (SHARED_LOCK) { if (shared == null) { - final FeatureFlagsRuntime created = create(connector, settings); + final FeatureFlagsRuntime created = create(bridge, settings); created.start(); shared = created; } @@ -86,18 +86,18 @@ public static void release() { stopped.stop(); } - static FeatureFlagsRuntime create(final Connector connector, final Settings settings) { + static FeatureFlagsRuntime create(final AgentBridge bridge, final Settings settings) { final String sourceName = settings.configurationSource(); if (sourceName == null) { throw new IllegalStateException("Feature Flags is disabled"); } final boolean remoteConfig = CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(sourceName); - final RuntimeServices services = new RuntimeServices(connector.healthMetrics()); + final RuntimeServices services = new RuntimeServices(bridge.healthMetrics()); final ConfigurationService source; final EventTransport transport; if (remoteConfig) { // TODO Support Remote Configuration without the Datadog Java agent. - final ConfigurationSource remoteConfiguration = connector.remoteConfiguration(); + final ConfigurationSource remoteConfiguration = bridge.remoteConfiguration(); if (remoteConfiguration == null) { throw new IllegalStateException( "Feature Flags Remote Configuration source requires the Datadog Java agent with" @@ -105,10 +105,10 @@ static FeatureFlagsRuntime create(final Connector connector, final Settings sett } source = new RemoteConfigurationService(remoteConfiguration); // Remote Configuration never falls back to direct intake. - transport = connector.eventProxy(); + transport = bridge.eventProxy(); } else { source = new CdnConfigurationSource(settings, services); - final EventTransport proxy = connector.eventProxy(); + final EventTransport proxy = bridge.eventProxy(); final EventTransport direct = DirectIntakeTransport.create(settings); transport = proxy == null ? direct : direct == null ? proxy : new FallbackTransport(proxy, direct); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java index e8fc19c1fcb..b2496d0f3dd 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/CanonicalFixtureTest.java @@ -7,8 +7,8 @@ import static org.mockito.Mockito.mock; import com.datadog.openfeature.internal.JsonReading; +import com.datadog.openfeature.internal.bridge.AgentBridge; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.ufc.UniversalFlagConfigParser; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; @@ -45,7 +45,7 @@ void canonicalFixturesArePresent() throws IOException { @ParameterizedTest(name = "{0}") void evaluateCanonicalFixture(final FixtureCase testCase) throws IOException { final DDEvaluator evaluator = - new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()); + new DDEvaluator(mock(Runnable.class), AgentBridge.NONE, TestSettings.of()); evaluator.accept( UniversalFlagConfigParser.parse( Files.readAllBytes(fixtureRoot().resolve("ufc-config.json")))); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java index 359f3b65c7b..4f8bbca6e83 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorSpanEnrichmentTest.java @@ -10,8 +10,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.mock; +import com.datadog.openfeature.internal.bridge.AgentBridge; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.Flag; import com.datadog.openfeature.internal.ufc.ServerConfiguration; @@ -57,7 +57,7 @@ private static ProviderEvaluation evaluate(final Integer serialId) { final DDEvaluator evaluator = new DDEvaluator( mock(Runnable.class), - Connector.NONE, + AgentBridge.NONE, TestSettings.of("experimental.flagging.provider.span.enrichment.enabled", "true")); evaluator.accept(new ServerConfiguration("", "", true, null, flags)); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java index 964c9b4e149..b8ce893dd4f 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DDEvaluatorTest.java @@ -23,8 +23,8 @@ import static org.mockito.Mockito.when; import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.bridge.AgentBridge; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.ufc.Allocation; @@ -63,7 +63,7 @@ public class DDEvaluatorTest { private static DDEvaluator newEvaluator(final Runnable configCallback) { - return new DDEvaluator(configCallback, Connector.NONE, TestSettings.of()); + return new DDEvaluator(configCallback, AgentBridge.NONE, TestSettings.of()); } /** Creates an evaluator whose runtime polls a local CDN that has no configuration. */ @@ -71,7 +71,7 @@ private static DDEvaluator newInitializableEvaluator( final Runnable configCallback, final LocalHttpServer cdn) { return new DDEvaluator( configCallback, - Connector.NONE, + AgentBridge.NONE, TestSettings.of( "feature.flags.configuration.source.agentless.base.url", cdn.uri("/").toString(), @@ -387,7 +387,7 @@ private static ExposureEvent exposureFor(final Integer serialId) { final List dispatched = new ArrayList<>(); final ExposureDeduplicationCache cache = new ExposureDeduplicationCache(); final DDEvaluator evaluator = - new DDEvaluator(mock(Runnable.class), Connector.NONE, TestSettings.of()) { + new DDEvaluator(mock(Runnable.class), AgentBridge.NONE, TestSettings.of()) { @Override ExposureDeduplicationCache exposureCache() { return cache; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DatadogExposureLoggingTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DatadogExposureLoggingTest.java index 594b48568e9..846b4bc31cc 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/DatadogExposureLoggingTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DatadogExposureLoggingTest.java @@ -10,12 +10,12 @@ import com.datadog.openfeature.Provider.Options; import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.EventTransport; +import com.datadog.openfeature.internal.bridge.HealthMetrics; +import com.datadog.openfeature.internal.bridge.SpanEnricher; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.ConfigurationSource; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.EventTransport; -import com.datadog.openfeature.internal.connector.HealthMetrics; -import com.datadog.openfeature.internal.connector.SpanEnricher; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.Flag; @@ -92,7 +92,7 @@ private Client start(final String exposuresDatadogLoggingEnabled) throws Excepti final DDEvaluator evaluator = new DDEvaluator( () -> {}, - new ProxyConnector((route, json) -> {}), + new ProxyAgentBridge((route, json) -> {}), TestSettings.of(settings.toArray(new String[0]))) { @Override void recordExposure(final ExposureEvent event) { @@ -118,11 +118,11 @@ private static ServerConfiguration configuration() { return new ServerConfiguration("", "", false, null, flags); } - /** A connector only providing an event platform proxy. */ - private static final class ProxyConnector implements Connector { + /** A bridge only providing an event platform proxy. */ + private static final class ProxyAgentBridge implements AgentBridge { private final EventTransport proxy; - ProxyConnector(final EventTransport proxy) { + ProxyAgentBridge(final EventTransport proxy) { this.proxy = proxy; } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java index b8ee1ed2497..70d2c6f468a 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureLoggingHookTest.java @@ -14,8 +14,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.datadog.openfeature.Provider.Options; +import com.datadog.openfeature.internal.bridge.AgentBridge; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.Flag; @@ -198,7 +198,8 @@ void sendsNothingWithoutTheDetailsAnExposureNeeds() { @Test void sendsNothingWithoutAnInitializedDatadogEvaluator() { final ExposureLoggingHook withoutEvaluator = new ExposureLoggingHook<>(() -> null); - final DDEvaluator uninitialized = new DDEvaluator(() -> {}, Connector.NONE, TestSettings.of()); + final DDEvaluator uninitialized = + new DDEvaluator(() -> {}, AgentBridge.NONE, TestSettings.of()); final ExposureLoggingHook withoutRuntime = new ExposureLoggingHook<>(() -> uninitialized); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureTestEvaluator.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureTestEvaluator.java index 8ec90e6c0ed..db8e81a2c08 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureTestEvaluator.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ExposureTestEvaluator.java @@ -2,8 +2,8 @@ import static org.mockito.Mockito.mock; +import com.datadog.openfeature.internal.bridge.AgentBridge; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.Connector; import com.datadog.openfeature.internal.exposure.ExposureDeduplicationCache; import com.datadog.openfeature.internal.exposure.ExposureEvent; import com.datadog.openfeature.internal.ufc.ServerConfiguration; @@ -28,7 +28,7 @@ class ExposureTestEvaluator extends DDEvaluator { * @param sendToDatadog whether exposures are sent to Datadog. */ ExposureTestEvaluator(final ServerConfiguration configuration, final boolean sendToDatadog) { - super(mock(Runnable.class), Connector.NONE, TestSettings.of()); + super(mock(Runnable.class), AgentBridge.NONE, TestSettings.of()); this.sendToDatadog = sendToDatadog; accept(configuration); } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java index 09bf50bf6ab..9f08140abd0 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/ProviderTest.java @@ -21,12 +21,12 @@ import com.datadog.openfeature.Provider.Options; import com.datadog.openfeature.internal.JsonReading; import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.EventTransport; +import com.datadog.openfeature.internal.bridge.HealthMetrics; +import com.datadog.openfeature.internal.bridge.SpanEnricher; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.ConfigurationSource; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.EventTransport; -import com.datadog.openfeature.internal.connector.HealthMetrics; -import com.datadog.openfeature.internal.connector.SpanEnricher; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.Flag; import com.datadog.openfeature.internal.ufc.ServerConfiguration; @@ -94,15 +94,15 @@ public void tearDown() { * configuration. Configurations are then dispatched with {@link #dispatch}. */ private Provider newProvider(final Options options) { - return newProvider(options, Connector.NONE); + return newProvider(options, AgentBridge.NONE); } - private Provider newProvider(final Options options, final Connector connector) { + private Provider newProvider(final Options options, final AgentBridge bridge) { final Provider[] providerRef = new Provider[1]; this.evaluator = new DDEvaluator( () -> providerRef[0].onConfigurationChange(), - connector, + bridge, TestSettings.of( "feature.flags.configuration.source.agentless.base.url", this.cdn.uri("/").toString())); @@ -358,7 +358,7 @@ public void testDisabledProductFailsInitialization() { final DDEvaluator disabled = new DDEvaluator( () -> providerRef[0].onConfigurationChange(), - Connector.NONE, + AgentBridge.NONE, TestSettings.of("feature.flags.enabled", "false")); providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), disabled); @@ -375,7 +375,7 @@ public void testRemoteConfigurationWithoutAgentFailsInitialization() { final DDEvaluator remoteConfig = new DDEvaluator( () -> providerRef[0].onConfigurationChange(), - Connector.NONE, + AgentBridge.NONE, TestSettings.of("feature.flags.configuration.source", "remote_config")); providerRef[0] = new Provider(new Options().initTimeout(10, MILLISECONDS), remoteConfig); @@ -402,7 +402,7 @@ public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Except final Provider provider = newProvider( new Options().initTimeout(10, SECONDS), - new ProxyConnector( + new ProxyAgentBridge( (route, json) -> { if ("flagevaluation".equals(route)) { posted.add(json); @@ -577,11 +577,11 @@ private static Map firstFlagEvaluation(final byte[] payload) { return (Map) ((List) request.get("flagEvaluations")).get(0); } - /** A connector only providing an event platform proxy. */ - private static final class ProxyConnector implements Connector { + /** A bridge only providing an event platform proxy. */ + private static final class ProxyAgentBridge implements AgentBridge { private final EventTransport proxy; - ProxyConnector(final EventTransport proxy) { + ProxyAgentBridge(final EventTransport proxy) { this.proxy = proxy; } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java index e9f261e9ad3..8267b28993c 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/SpanEnrichmentHookTest.java @@ -6,7 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.bridge.SpanEnricher; import dev.openfeature.sdk.FlagEvaluationDetails; import dev.openfeature.sdk.FlagValueType; import dev.openfeature.sdk.Hook; diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java index 73300451d42..e462b831132 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -11,12 +11,12 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.EventTransport; +import com.datadog.openfeature.internal.bridge.HealthMetrics; +import com.datadog.openfeature.internal.bridge.SpanEnricher; import com.datadog.openfeature.internal.config.TestSettings; -import com.datadog.openfeature.internal.connector.ConfigurationSource; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.EventTransport; -import com.datadog.openfeature.internal.connector.HealthMetrics; -import com.datadog.openfeature.internal.connector.SpanEnricher; import com.datadog.openfeature.internal.ufc.ServerConfiguration; import java.nio.charset.StandardCharsets; import java.util.List; @@ -35,7 +35,7 @@ void disabledProductIsRejected() { IllegalStateException.class, () -> FeatureFlagsRuntime.create( - Connector.NONE, TestSettings.of("feature.flags.enabled", "false"))); + AgentBridge.NONE, TestSettings.of("feature.flags.enabled", "false"))); assertEquals("Feature Flags is disabled", error.getMessage()); } @@ -47,7 +47,7 @@ void remoteConfigurationRequiresTheJavaAgent() { IllegalStateException.class, () -> FeatureFlagsRuntime.create( - Connector.NONE, + AgentBridge.NONE, TestSettings.of("feature.flags.configuration.source", "remote_config"))); assertTrue(error.getMessage().contains("requires the Datadog Java agent")); @@ -56,7 +56,7 @@ void remoteConfigurationRequiresTheJavaAgent() { @Test void agentlessWithoutApiKeyOrProxyDisablesEventDelivery() { final FeatureFlagsRuntime runtime = - FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + FeatureFlagsRuntime.create(AgentBridge.NONE, TestSettings.of()); assertNull(runtime.evaluations()); } @@ -65,7 +65,7 @@ void agentlessWithoutApiKeyOrProxyDisablesEventDelivery() { void evaluationCountsKillSwitchDisablesTheEvaluationPipeline() { final FeatureFlagsRuntime runtime = FeatureFlagsRuntime.create( - new TestConnector(null, (route, json) -> {}), + new TestAgentBridge(null, (route, json) -> {}), TestSettings.of("flagging.evaluation.counts.enabled", "false")); assertNull(runtime.evaluations()); @@ -75,7 +75,7 @@ void evaluationCountsKillSwitchDisablesTheEvaluationPipeline() { void exposuresDatadogLoggingKillSwitchDisablesTheExposurePipelineButKeepsTheCache() { final FeatureFlagsRuntime runtime = FeatureFlagsRuntime.create( - new TestConnector(null, (route, json) -> {}), + new TestAgentBridge(null, (route, json) -> {}), TestSettings.of("feature.flags.exposures.datadog.logging.enabled", "false")); assertFalse(runtime.isExposureDeliveryEnabled()); @@ -86,7 +86,7 @@ void exposuresDatadogLoggingKillSwitchDisablesTheExposurePipelineButKeepsTheCach @Test void agentlessWithoutApiKeyOrProxyKeepsTheExposureCache() { final FeatureFlagsRuntime runtime = - FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + FeatureFlagsRuntime.create(AgentBridge.NONE, TestSettings.of()); assertFalse(runtime.isExposureDeliveryEnabled()); assertNotNull(runtime.exposureCache()); @@ -95,7 +95,8 @@ void agentlessWithoutApiKeyOrProxyKeepsTheExposureCache() { @Test void eventProxyEnablesBothPipelines() { final FeatureFlagsRuntime runtime = - FeatureFlagsRuntime.create(new TestConnector(null, (route, json) -> {}), TestSettings.of()); + FeatureFlagsRuntime.create( + new TestAgentBridge(null, (route, json) -> {}), TestSettings.of()); assertNotNull(runtime.evaluations()); assertTrue(runtime.isExposureDeliveryEnabled()); @@ -106,7 +107,7 @@ void remoteConfigurationDeliversParsedConfigurationsAndReplaysToLateListeners() final TestConfigurationSource source = new TestConfigurationSource(); final FeatureFlagsRuntime runtime = FeatureFlagsRuntime.create( - new TestConnector(source, null), + new TestAgentBridge(source, null), TestSettings.of("feature.flags.configuration.source", "remote_config")); final List early = new CopyOnWriteArrayList<>(); runtime.addConfigurationListener(early::add); @@ -136,7 +137,7 @@ void removedListenersAreNotNotified() { final TestConfigurationSource source = new TestConfigurationSource(); final FeatureFlagsRuntime runtime = FeatureFlagsRuntime.create( - new TestConnector(source, null), + new TestAgentBridge(source, null), TestSettings.of("feature.flags.configuration.source", "remote_config")); @SuppressWarnings("unchecked") final Consumer listener = mock(Consumer.class); @@ -157,7 +158,7 @@ void failingListenerDoesNotStarveOtherListeners() { final TestConfigurationSource source = new TestConfigurationSource(); final FeatureFlagsRuntime runtime = FeatureFlagsRuntime.create( - new TestConnector(source, null), + new TestAgentBridge(source, null), TestSettings.of("feature.flags.configuration.source", "remote_config")); final List received = new CopyOnWriteArrayList<>(); runtime.addConfigurationListener( @@ -178,11 +179,11 @@ void failingListenerDoesNotStarveOtherListeners() { @Test void sharedRuntimeIsReferenceCounted() { final TestConfigurationSource source = new TestConfigurationSource(); - final Connector connector = new TestConnector(source, null); + final AgentBridge bridge = new TestAgentBridge(source, null); final FeatureFlagsRuntime first = FeatureFlagsRuntime.acquire( - connector, TestSettings.of("feature.flags.configuration.source", "remote_config")); - final FeatureFlagsRuntime second = FeatureFlagsRuntime.acquire(connector, TestSettings.of()); + bridge, TestSettings.of("feature.flags.configuration.source", "remote_config")); + final FeatureFlagsRuntime second = FeatureFlagsRuntime.acquire(bridge, TestSettings.of()); try { assertSame(first, second); FeatureFlagsRuntime.release(); @@ -205,13 +206,13 @@ void failedStartDoesNotCacheTheSharedRuntime() { IllegalStateException.class, () -> FeatureFlagsRuntime.acquire( - new TestConnector(failing, null), + new TestAgentBridge(failing, null), TestSettings.of("feature.flags.configuration.source", "remote_config"))); final TestConfigurationSource source = new TestConfigurationSource(); final FeatureFlagsRuntime runtime = FeatureFlagsRuntime.acquire( - new TestConnector(source, null), + new TestAgentBridge(source, null), TestSettings.of("feature.flags.configuration.source", "remote_config")); try { assertNotNull(runtime); @@ -224,7 +225,7 @@ void failedStartDoesNotCacheTheSharedRuntime() { @Test void recordsExposuresOnlyWhenDeliveryIsEnabled() { final FeatureFlagsRuntime runtime = - FeatureFlagsRuntime.create(Connector.NONE, TestSettings.of()); + FeatureFlagsRuntime.create(AgentBridge.NONE, TestSettings.of()); // Must not throw without an exposure pipeline. runtime.recordExposure( new com.datadog.openfeature.internal.exposure.ExposureEvent( @@ -247,11 +248,12 @@ public AutoCloseable subscribe(final Consumer listener) { } } - private static final class TestConnector implements Connector { + private static final class TestAgentBridge implements AgentBridge { private final ConfigurationSource remoteConfiguration; private final EventTransport eventProxy; - TestConnector(final ConfigurationSource remoteConfiguration, final EventTransport eventProxy) { + TestAgentBridge( + final ConfigurationSource remoteConfiguration, final EventTransport eventProxy) { this.remoteConfiguration = remoteConfiguration; this.eventProxy = eventProxy; } From fff162e2c9eeebb4f2afbf6444fd50bf17e28393 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 08:38:46 +0200 Subject: [PATCH 48/50] refactor(ffe): Rename the dd-openfeature-connector instrumentation to bridge Rename the instrumentation module to dd-openfeature-bridge, with JavaAgentBridge and DetectBridgeAdvice, to match the SDK agent bridge. --- .../build.gradle | 0 .../gradle.lockfile | 2 +- .../DDOpenFeatureInstrumentation.java | 6 +-- .../openfeature/AgentConfigurationSource.java | 2 +- .../openfeature/AgentEventProxy.java | 4 +- .../openfeature/AgentHealthMetrics.java | 2 +- .../openfeature/AgentSpanEnricher.java | 2 +- .../openfeature/DetectBridgeAdvice.java | 11 +++++ .../openfeature/JavaAgentBridge.java} | 24 +++++------ .../ConfigurationResolutionParityTest.java | 8 ++-- ...tureInstrumentationDisabledForkedTest.java | 6 +-- .../DDOpenFeatureInstrumentationTest.java | 42 +++++++++---------- .../openfeature/DetectConnectorAdvice.java | 11 ----- dd-openfeature/README.md | 6 +-- settings.gradle.kts | 2 +- 15 files changed, 64 insertions(+), 64 deletions(-) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/build.gradle (100%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/gradle.lockfile (99%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java (91%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java (89%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java (82%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java (90%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java (92%) create mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/DetectBridgeAdvice.java rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java => dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentBridge.java} (61%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java (95%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java (81%) rename dd-java-agent/instrumentation/datadog/openfeature/{dd-openfeature-connector => dd-openfeature-bridge}/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java (71%) delete mode 100644 dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/build.gradle similarity index 100% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/build.gradle rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/build.gradle diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/gradle.lockfile similarity index 99% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/gradle.lockfile index 40c9c574e4b..fcdd711e142 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/gradle.lockfile +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/gradle.lockfile @@ -1,7 +1,7 @@ # 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 :dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector:dependencies --write-locks +# To regenerate this file, run: ./gradlew :dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-bridge:dependencies --write-locks cafe.cryptography:curve25519-elisabeth:0.1.0=testRuntimeClasspath cafe.cryptography:ed25519-elisabeth:0.1.0=testRuntimeClasspath ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java similarity index 91% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java index dc909890988..5b47eaeb6c4 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentation.java @@ -31,7 +31,7 @@ public boolean isApplicable(final Set enabledSystems) { @Override public String instrumentedType() { - return "com.datadog.openfeature.internal.connector.Connectors"; + return "com.datadog.openfeature.internal.bridge.AgentBridges"; } @Override @@ -42,7 +42,7 @@ public String[] helperClassNames() { packageName + ".AgentEventProxy", packageName + ".AgentHealthMetrics", packageName + ".AgentSpanEnricher", - packageName + ".JavaAgentConnector", + packageName + ".JavaAgentBridge", }; } @@ -50,6 +50,6 @@ public String[] helperClassNames() { public void methodAdvice(final MethodTransformer transformer) { transformer.applyAdvice( isMethod().and(isStatic()).and(named("detect")).and(takesNoArguments()), - packageName + ".DetectConnectorAdvice"); + packageName + ".DetectBridgeAdvice"); } } diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java similarity index 89% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java index d25d4475212..40ec60469e8 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentConfigurationSource.java @@ -1,6 +1,6 @@ package datadog.trace.instrumentation.openfeature; -import com.datadog.openfeature.internal.connector.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import java.util.function.Consumer; diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java similarity index 82% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java index 5c30077065d..cd1e4c049de 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentEventProxy.java @@ -1,7 +1,7 @@ package datadog.trace.instrumentation.openfeature; -import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; -import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.bridge.EventProxyUnavailableException; +import com.datadog.openfeature.internal.bridge.EventTransport; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import java.io.IOException; diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java similarity index 90% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java index abee16aeae1..69615c45a65 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentHealthMetrics.java @@ -1,6 +1,6 @@ package datadog.trace.instrumentation.openfeature; -import com.datadog.openfeature.internal.connector.HealthMetrics; +import com.datadog.openfeature.internal.bridge.HealthMetrics; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import javax.annotation.Nullable; diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java similarity index 92% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java index 5e0f87715cf..8c947158161 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/AgentSpanEnricher.java @@ -1,6 +1,6 @@ package datadog.trace.instrumentation.openfeature; -import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.bridge.SpanEnricher; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import javax.annotation.Nullable; diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/DetectBridgeAdvice.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/DetectBridgeAdvice.java new file mode 100644 index 00000000000..d4fba3958ad --- /dev/null +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/DetectBridgeAdvice.java @@ -0,0 +1,11 @@ +package datadog.trace.instrumentation.openfeature; + +import com.datadog.openfeature.internal.bridge.AgentBridge; +import net.bytebuddy.asm.Advice; + +public class DetectBridgeAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void detect(@Advice.Return(readOnly = false) AgentBridge bridge) { + bridge = JavaAgentBridge.connect(bridge); + } +} diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentBridge.java similarity index 61% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentBridge.java index f4d463dab3a..5f8dea90746 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentConnector.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/main/java11/datadog/trace/instrumentation/openfeature/JavaAgentBridge.java @@ -1,20 +1,20 @@ package datadog.trace.instrumentation.openfeature; -import com.datadog.openfeature.internal.connector.ConfigurationSource; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.EventTransport; -import com.datadog.openfeature.internal.connector.HealthMetrics; -import com.datadog.openfeature.internal.connector.SpanEnricher; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.EventTransport; +import com.datadog.openfeature.internal.bridge.HealthMetrics; +import com.datadog.openfeature.internal.bridge.SpanEnricher; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import javax.annotation.Nullable; -/** The connector backed by the agent Feature Flags backend. */ -public final class JavaAgentConnector implements Connector { +/** The bridge backed by the agent Feature Flags backend. */ +public final class JavaAgentBridge implements AgentBridge { private final FeatureFlaggingGateway.Backend backend; private final HealthMetrics healthMetrics; private final SpanEnricher spanEnricher; - JavaAgentConnector(final FeatureFlaggingGateway.Backend backend) { + JavaAgentBridge(final FeatureFlaggingGateway.Backend backend) { this.backend = backend; this.healthMetrics = new AgentHealthMetrics(backend); this.spanEnricher = new AgentSpanEnricher(backend); @@ -23,12 +23,12 @@ public final class JavaAgentConnector implements Connector { /** * Connects the SDK to the agent backend. * - * @param fallback the connector to use if the agent backend is not started. - * @return the agent connector, or the fallback one. + * @param fallback the bridge to use if the agent backend is not started. + * @return the agent bridge, or the fallback one. */ - public static Connector connect(final Connector fallback) { + public static AgentBridge connect(final AgentBridge fallback) { final FeatureFlaggingGateway.Backend backend = FeatureFlaggingGateway.backend(); - return backend == null ? fallback : new JavaAgentConnector(backend); + return backend == null ? fallback : new JavaAgentBridge(backend); } @Nullable diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java similarity index 95% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java index ea366116937..9c3f9184968 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/ConfigurationResolutionParityTest.java @@ -9,8 +9,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.datadog.openfeature.internal.bridge.AgentBridge; import com.datadog.openfeature.internal.config.Settings; -import com.datadog.openfeature.internal.connector.Connector; import datadog.trace.api.InstrumenterConfig; import datadog.trace.test.junit.utils.config.WithConfigExtension; import java.util.HashMap; @@ -75,10 +75,10 @@ private static String agentSource(final Map settings) { * for the agent, which hold the same values. */ private static String sdkSource(final Map settings) { - final Connector connector = mock(Connector.class); - when(connector.setting(anyString())) + final AgentBridge bridge = mock(AgentBridge.class); + when(bridge.setting(anyString())) .thenAnswer(invocation -> settings.get(invocation.getArgument(0))); - return Settings.load(connector).configurationSource(); + return Settings.load(bridge).configurationSource(); } private static void putIfNotNull( diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java similarity index 81% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java index 0c7dab1cfb8..1dcca26aa0c 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationDisabledForkedTest.java @@ -3,8 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.mock; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.Connectors; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.AgentBridges; import datadog.trace.agent.test.AbstractInstrumentationTest; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.test.junit.utils.config.WithConfig; @@ -21,6 +21,6 @@ void unregisterBackend() { @Test void doesNotInstrumentWhenFeatureFlagsIsDisabled() { FeatureFlaggingGateway.register(mock(FeatureFlaggingGateway.Backend.class)); - assertSame(Connector.NONE, Connectors.detect()); + assertSame(AgentBridge.NONE, AgentBridges.detect()); } } diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java similarity index 71% rename from dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java rename to dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java index 10c46a4e4ac..492fce5e34d 100644 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java +++ b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-bridge/src/test/java/datadog/trace/instrumentation/openfeature/DDOpenFeatureInstrumentationTest.java @@ -11,11 +11,11 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.datadog.openfeature.internal.connector.ConfigurationSource; -import com.datadog.openfeature.internal.connector.Connector; -import com.datadog.openfeature.internal.connector.Connectors; -import com.datadog.openfeature.internal.connector.EventProxyUnavailableException; -import com.datadog.openfeature.internal.connector.EventTransport; +import com.datadog.openfeature.internal.bridge.AgentBridge; +import com.datadog.openfeature.internal.bridge.AgentBridges; +import com.datadog.openfeature.internal.bridge.ConfigurationSource; +import com.datadog.openfeature.internal.bridge.EventProxyUnavailableException; +import com.datadog.openfeature.internal.bridge.EventTransport; import datadog.trace.agent.test.AbstractInstrumentationTest; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import java.util.function.Consumer; @@ -30,15 +30,15 @@ void unregisterBackend() { } @Test - void detectsTheAgentConnector() { + void detectsTheAgentBridge() { final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); FeatureFlaggingGateway.register(backend); - assertInstanceOf(JavaAgentConnector.class, Connectors.detect()); + assertInstanceOf(JavaAgentBridge.class, AgentBridges.detect()); } @Test - void keepsTheDefaultConnectorWithoutBackend() { - assertSame(Connector.NONE, Connectors.detect()); + void keepsTheDefaultBridgeWithoutBackend() { + assertSame(AgentBridge.NONE, AgentBridges.detect()); } @Test @@ -46,10 +46,10 @@ void delegatesSettingsAndHealthMetrics() { final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); FeatureFlaggingGateway.register(backend); when(backend.setting("site")).thenReturn("datadoghq.eu"); - final Connector connector = Connectors.detect(); + final AgentBridge bridge = AgentBridges.detect(); - assertEquals("datadoghq.eu", connector.setting("site")); - connector.healthMetrics().count("metric", 2, "reason"); + assertEquals("datadoghq.eu", bridge.setting("site")); + bridge.healthMetrics().count("metric", 2, "reason"); verify(backend).count("metric", 2, "reason"); } @@ -57,13 +57,13 @@ void delegatesSettingsAndHealthMetrics() { void exposesRemoteConfigurationOnlyWhenAvailable() throws Exception { final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); FeatureFlaggingGateway.register(backend); - final Connector connector = Connectors.detect(); - assertNull(connector.remoteConfiguration()); + final AgentBridge bridge = AgentBridges.detect(); + assertNull(bridge.remoteConfiguration()); when(backend.isRemoteConfigAvailable()).thenReturn(true); final AutoCloseable subscription = mock(AutoCloseable.class); when(backend.subscribeRemoteConfig(any())).thenReturn(subscription); - final ConfigurationSource source = connector.remoteConfiguration(); + final ConfigurationSource source = bridge.remoteConfiguration(); final Consumer listener = content -> {}; assertSame(subscription, source.subscribe(listener)); @@ -74,11 +74,11 @@ void exposesRemoteConfigurationOnlyWhenAvailable() throws Exception { void postsThroughTheEventProxyOnlyWhenAvailable() throws Exception { final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); FeatureFlaggingGateway.register(backend); - final Connector connector = Connectors.detect(); - assertNull(connector.eventProxy()); + final AgentBridge bridge = AgentBridges.detect(); + assertNull(bridge.eventProxy()); when(backend.isEventProxyAvailable()).thenReturn(true); - final EventTransport proxy = connector.eventProxy(); + final EventTransport proxy = bridge.eventProxy(); final byte[] payload = "{}".getBytes(UTF_8); when(backend.postEvent("exposures", payload)).thenReturn(true); proxy.post("exposures", payload); @@ -92,10 +92,10 @@ void postsThroughTheEventProxyOnlyWhenAvailable() throws Exception { void delegatesSpanEnrichment() { final FeatureFlaggingGateway.Backend backend = mock(FeatureFlaggingGateway.Backend.class); FeatureFlaggingGateway.register(backend); - final Connector connector = Connectors.detect(); + final AgentBridge bridge = AgentBridges.detect(); - connector.spanEnricher().serialId(42, true, "user"); - connector.spanEnricher().runtimeDefault("flag", "default"); + bridge.spanEnricher().serialId(42, true, "user"); + bridge.spanEnricher().runtimeDefault("flag", "default"); verify(backend).enrichSerialId(42, true, "user"); verify(backend).enrichRuntimeDefault("flag", "default"); diff --git a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java b/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java deleted file mode 100644 index deb9d2d7bbe..00000000000 --- a/dd-java-agent/instrumentation/datadog/openfeature/dd-openfeature-connector/src/main/java11/datadog/trace/instrumentation/openfeature/DetectConnectorAdvice.java +++ /dev/null @@ -1,11 +0,0 @@ -package datadog.trace.instrumentation.openfeature; - -import com.datadog.openfeature.internal.connector.Connector; -import net.bytebuddy.asm.Advice; - -public class DetectConnectorAdvice { - @Advice.OnMethodExit(suppress = Throwable.class) - public static void detect(@Advice.Return(readOnly = false) Connector connector) { - connector = JavaAgentConnector.connect(connector); - } -} diff --git a/dd-openfeature/README.md b/dd-openfeature/README.md index 6f649411f76..5260bacbce4 100644 --- a/dd-openfeature/README.md +++ b/dd-openfeature/README.md @@ -138,9 +138,9 @@ This setting stops exposures only. Flag evaluation events have their own setting ## Design notes for contributors -The SDK does not use any dd-trace-java internal module. Its parts that the agent can provide are behind the `com.datadog.openfeature.internal.connector` SPI: +The SDK does not use any dd-trace-java internal module. Its parts that the agent can provide are behind the `com.datadog.openfeature.internal.bridge` SPI: -* `Connectors.detect()` returns `Connector.NONE` without the agent. -* The `:dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector` instrumentation replaces its result with a connector delegating to the agent backend (`products/feature-flagging/feature-flagging-lib`) through the bootstrap `FeatureFlaggingGateway`. +* `AgentBridges.detect()` returns `AgentBridge.NONE` without the agent. +* The `:dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-bridge` instrumentation replaces its result with a bridge delegating to the agent backend (`products/feature-flagging/feature-flagging-lib`) through the bootstrap `FeatureFlaggingGateway`. * The SPI is the only contract crossing SDK and agent versions. It must evolve in a backward compatible way, and the instrumentation references to it are checked by muzzle. * UFC parsing, evaluation, event payloads and batching stay in the SDK. The agent forwards raw Remote Configuration documents and event payloads. diff --git a/settings.gradle.kts b/settings.gradle.kts index 8ac120a8520..3f9546a890a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -343,7 +343,7 @@ include( ":dd-java-agent:instrumentation:cxf-2.1", ":dd-java-agent:instrumentation:datadog:asm:iast-instrumenter", ":dd-java-agent:instrumentation:datadog:dynamic-instrumentation:span-origin", - ":dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-connector", + ":dd-java-agent:instrumentation:datadog:openfeature:dd-openfeature-bridge", ":dd-java-agent:instrumentation:datadog:profiling:enable-wallclock-profiling", ":dd-java-agent:instrumentation:datadog:profiling:exception-profiling", ":dd-java-agent:instrumentation:datadog:tracing:trace-annotation", From d5f990847a0eac70960925fb0dba98b66bbde6d7 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 09:11:23 +0200 Subject: [PATCH 49/50] fix(ffe): Log ignored Feature Flags settings and configuration responses Warn when a positive integer setting is not positive, as the agent did, and when the configuration endpoint returns an unexpected HTTP status, so a wrong custom endpoint is diagnosable. Clarify that configuration listeners receive null when the configuration is removed. --- .../datadog/openfeature/internal/ConfigurationService.java | 3 ++- .../com/datadog/openfeature/internal/config/Settings.java | 6 +++++- .../internal/delivery/CdnConfigurationSource.java | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java index cadbbd0d8b2..465db6b0444 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ConfigurationService.java @@ -9,7 +9,8 @@ public interface ConfigurationService extends AutoCloseable { * Starts delivering configurations. Implementations may deliver the first configuration before * returning. * - * @param listener the listener receiving configurations, or {@code null} when removed. + * @param listener the listener receiving configurations, and {@code null} when the configuration + * is removed. */ void start(Consumer listener); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index 02893fce0c9..11bf5f4e91c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -258,7 +258,11 @@ private int getPositiveInt(final String key, final int defaultValue) { } try { final int parsed = Integer.parseInt(value); - return parsed > 0 ? parsed : defaultValue; + if (parsed > 0) { + return parsed; + } + LOGGER.warn("Invalid value for {}: {}. The value must be positive", key, value); + return defaultValue; } catch (final NumberFormatException e) { LOGGER.warn("Invalid value for {}: {}", key, value); } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java index fa1b669db02..cfb38849cab 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -222,6 +222,7 @@ private boolean apply(final HttpResponse response) { return false; } if (status != HttpURLConnection.HTTP_OK || response.body() == null) { + warn("Feature Flags configuration endpoint returned HTTP {}", status); return false; } final ServerConfiguration configuration; From 620390b286be0c3dd6f9e18ff40a1205721ffe7f Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 15 Oct 2026 10:10:07 +0200 Subject: [PATCH 50/50] fix(ffe): Fix HTTP call deadlock on cancellation HttpCall cancelled the HttpClient exchange while holding its own monitor, and the HttpClient delivered the response body subscription while holding its internal lock and then took that monitor, deadlocking on a timeout or interruption racing with the response body subscription. Track the cancellation and subscription with volatile fields instead. --- .../openfeature/internal/delivery/HttpCall.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/HttpCall.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/HttpCall.java index 26abe79e754..d5a2f9876dd 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/HttpCall.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/HttpCall.java @@ -22,8 +22,8 @@ final class HttpCall { private final long startedNanos = System.nanoTime(); private final long timeoutNanos; private final CompletableFuture> response; - private Flow.Subscription subscription; - private boolean cancelled; + private volatile Flow.Subscription subscription; + private volatile boolean cancelled; HttpCall( final HttpClient client, @@ -72,16 +72,17 @@ HttpResponse execute() throws IOException, InterruptedException { } /** Cancels the active exchange and any response body subscription. */ - synchronized void cancel() { + void cancel() { if (!this.cancelled && this.response.cancel(true)) { this.cancelled = true; - if (this.subscription != null) { - this.subscription.cancel(); + final Flow.Subscription subscription = this.subscription; + if (subscription != null) { + subscription.cancel(); } } } - private synchronized void subscribe(final Flow.Subscription subscription) { + private void subscribe(final Flow.Subscription subscription) { this.subscription = subscription; if (this.cancelled) { subscription.cancel();