diff --git a/.rubocop.yml b/.rubocop.yml index b6e17bd9b6f..463d36ef8e8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -47,6 +47,8 @@ Style: - "spec/datadog/kit/**/**" - "spec/datadog/profiling*" - "spec/datadog/profiling/**/*" + - "spec/datadog/open_feature*" + - "spec/datadog/open_feature/**/*" - "yard/**/*.rb" Layout: diff --git a/Matrixfile b/Matrixfile index 98fa441564f..9998677d39f 100644 --- a/Matrixfile +++ b/Matrixfile @@ -351,6 +351,10 @@ 'rails61-mysql2' => '❌ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ❌ 3.5 / ❌ jruby', 'rails8-mysql2' => '❌ 2.5 / ❌ 2.6 / ❌ 2.7 / ❌ 3.0 / ❌ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ❌ jruby', }, + 'open_feature' => { + 'openfeature-latest' => '❌ 2.5 / ❌ 2.6 / ❌ 2.7 / ❌ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ❌ jruby', + 'openfeature-min' => '❌ 2.5 / ❌ 2.6 / ❌ 2.7 / ❌ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ❌ jruby', + }, }.each_with_object({}) do |(tasks, spec_metadata), hash| # Explode arrays of task names into individual tasks # e.g. ['rails', 'railsdisableenv'] => {'...'} becomes 'rails7' => {'...'}, 'railsdisableenv7' => {'...'} diff --git a/Rakefile b/Rakefile index 4bf70667735..2bc5d1d6a0d 100644 --- a/Rakefile +++ b/Rakefile @@ -77,13 +77,13 @@ namespace :spec do :graphql, :graphql_unified_trace_patcher, :graphql_trace_patcher, :graphql_tracing_patcher, :rails, :railsredis, :railsredis_activesupport, :railsactivejob, :elasticsearch, :http, :redis, :sidekiq, :sinatra, :hanami, :hanami_autoinstrument, - :profiling, :core_with_libdatadog_api, :error_tracking] + :profiling, :core_with_libdatadog_api, :error_tracking, :open_feature] desc '' # "Explicitly hiding from `rake -T`" RSpec::Core::RakeTask.new(:main) do |t, args| t.pattern = 'spec/**/*_spec.rb' - t.exclude_pattern = 'spec/**/{appsec/integration,contrib,benchmark,redis,auto_instrument,opentelemetry,profiling,crashtracking,error_tracking,rubocop}/**/*_spec.rb,' \ - ' spec/**/{auto_instrument,opentelemetry,process_discovery,stable_config,ddsketch}_spec.rb,' \ + t.exclude_pattern = 'spec/**/{appsec/integration,contrib,benchmark,redis,auto_instrument,opentelemetry,open_feature,profiling,crashtracking,error_tracking,rubocop}/**/*_spec.rb,' \ + ' spec/**/{auto_instrument,opentelemetry,open_feature,process_discovery,stable_config,ddsketch}_spec.rb,' \ ' spec/datadog/gem_packaging_spec.rb' t.rspec_opts = args.to_a.join(' ') end @@ -125,6 +125,12 @@ namespace :spec do t.rspec_opts = args.to_a.join(' ') end + desc '' # "Explicitly hiding from `rake -T`" + RSpec::Core::RakeTask.new(:open_feature) do |t, args| + t.pattern = 'spec/datadog/open_feature/**/*_spec.rb,spec/datadog/open_feature_spec.rb' + t.rspec_opts = args.to_a.join(' ') + end + desc '' # "Explicitly hiding from `rake -T`" RSpec::Core::RakeTask.new(:rails) do |t, args| t.pattern = 'spec/datadog/tracing/contrib/rails/**/*_spec.rb' diff --git a/appraisal/ruby-3.1.rb b/appraisal/ruby-3.1.rb index 6269745e08a..7689d5a68ba 100644 --- a/appraisal/ruby-3.1.rb +++ b/appraisal/ruby-3.1.rb @@ -99,6 +99,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('openfeature', min: '0.3.1', gem: 'openfeature-sdk') appraise 'relational_db' do gem 'activerecord', '~> 7' diff --git a/appraisal/ruby-3.2.rb b/appraisal/ruby-3.2.rb index 0c65a83f0ec..9dd3432b007 100644 --- a/appraisal/ruby-3.2.rb +++ b/appraisal/ruby-3.2.rb @@ -144,6 +144,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('openfeature', min: '0.3.1', gem: 'openfeature-sdk') appraise 'relational_db' do gem 'activerecord', '~> 7' diff --git a/appraisal/ruby-3.3.rb b/appraisal/ruby-3.3.rb index cdeabe2e644..df9a01b3347 100644 --- a/appraisal/ruby-3.3.rb +++ b/appraisal/ruby-3.3.rb @@ -146,6 +146,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('openfeature', min: '0.3.1', gem: 'openfeature-sdk') appraise 'relational_db' do gem 'activerecord', '~> 7' diff --git a/appraisal/ruby-3.4.rb b/appraisal/ruby-3.4.rb index 92833c2f59e..177a35f2c61 100644 --- a/appraisal/ruby-3.4.rb +++ b/appraisal/ruby-3.4.rb @@ -145,6 +145,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('openfeature', min: '0.3.1', gem: 'openfeature-sdk') appraise 'relational_db' do # ActiveRecord locked because tests are failing with 7.1, which was attempted as a part of Ruby 3.4 testing in CI. @@ -186,7 +187,7 @@ gem 'resque' gem 'roda', '>= 2.0.0' gem 'semantic_logger', '~> 4.0' - # Note: Sidekiq 8 uses different timestamp formatting compared to prior versions. As long as + # NOTE: Sidekiq 8 uses different timestamp formatting compared to prior versions. As long as # versions <8 are supported, make sure there's some CI running both older and newer versions. gem 'sidekiq', '~> 8' gem 'sneakers', '>= 2.12.0' diff --git a/appraisal/ruby-3.5.rb b/appraisal/ruby-3.5.rb index 6b0542ea9b7..cb5c8d00893 100644 --- a/appraisal/ruby-3.5.rb +++ b/appraisal/ruby-3.5.rb @@ -94,6 +94,7 @@ build_coverage_matrix('dalli', [2]) build_coverage_matrix('karafka', min: '2.3.0') build_coverage_matrix('devise', min: '3.2.1') +build_coverage_matrix('openfeature', min: '0.3.1', gem: 'openfeature-sdk') appraise 'relational_db' do # ActiveRecord locked because tests are failing with 7.1, which was attempted as a part of Ruby 3.4 testing in CI. diff --git a/gemfiles/ruby_3.3_openfeature_latest.gemfile b/gemfiles/ruby_3.3_openfeature_latest.gemfile new file mode 100644 index 00000000000..9c78fb7fa78 --- /dev/null +++ b/gemfiles/ruby_3.3_openfeature_latest.gemfile @@ -0,0 +1,38 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "benchmark-ips", "~> 2.8" +gem "benchmark-memory", "< 0.2" +gem "climate_control", "~> 1.2.0" +gem "concurrent-ruby" +gem "dogstatsd-ruby", ">= 3.3.0", "!= 5.0.0", "!= 5.0.1", "!= 5.1.0" +gem "zstd-ruby" +gem "google-protobuf", ["~> 3.0", "!= 3.7.0", "!= 3.7.1"] +gem "json-schema", "< 3" +gem "memory_profiler", "~> 0.9" +gem "os", "~> 1.1" +gem "debug" +gem "byebug" +gem "pry" +gem "rake", ">= 10.5" +gem "rake-compiler", "~> 1.1", ">= 1.1.1" +gem "rspec", "~> 3.13" +gem "rspec-collection_matchers", "~> 1.1" +gem "rspec-wait", "~> 0" +gem "rspec_junit_formatter", ">= 0.5.1" +gem "simplecov", "~> 0.22.0" +gem "warning", "~> 1" +gem "webmock", ">= 3.10.0" +gem "webrick", ">= 1.7.0" +gem "openfeature-sdk" + +group :check do + +end + +group :dev do + +end + +gemspec path: "../" diff --git a/gemfiles/ruby_3.3_openfeature_min.gemfile b/gemfiles/ruby_3.3_openfeature_min.gemfile new file mode 100644 index 00000000000..f8ac9f41951 --- /dev/null +++ b/gemfiles/ruby_3.3_openfeature_min.gemfile @@ -0,0 +1,38 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "benchmark-ips", "~> 2.8" +gem "benchmark-memory", "< 0.2" +gem "climate_control", "~> 1.2.0" +gem "concurrent-ruby" +gem "dogstatsd-ruby", ">= 3.3.0", "!= 5.0.0", "!= 5.0.1", "!= 5.1.0" +gem "zstd-ruby" +gem "google-protobuf", ["~> 3.0", "!= 3.7.0", "!= 3.7.1"] +gem "json-schema", "< 3" +gem "memory_profiler", "~> 0.9" +gem "os", "~> 1.1" +gem "debug" +gem "byebug" +gem "pry" +gem "rake", ">= 10.5" +gem "rake-compiler", "~> 1.1", ">= 1.1.1" +gem "rspec", "~> 3.13" +gem "rspec-collection_matchers", "~> 1.1" +gem "rspec-wait", "~> 0" +gem "rspec_junit_formatter", ">= 0.5.1" +gem "simplecov", "~> 0.22.0" +gem "warning", "~> 1" +gem "webmock", ">= 3.10.0" +gem "webrick", ">= 1.7.0" +gem "openfeature-sdk", "= 0.3.1" + +group :check do + +end + +group :dev do + +end + +gemspec path: "../" diff --git a/lib/datadog.rb b/lib/datadog.rb index f09d63a60e5..93e64951683 100644 --- a/lib/datadog.rb +++ b/lib/datadog.rb @@ -8,6 +8,7 @@ require_relative 'datadog/profiling' require_relative 'datadog/appsec' require_relative 'datadog/di' +require_relative 'datadog/open_feature' # Line probes will not work on Ruby < 2.6 because of lack of :script_compiled # trace point. Activate DI automatically on supported Ruby versions but diff --git a/lib/datadog/core/configuration/components.rb b/lib/datadog/core/configuration/components.rb index 09fccc0879d..7c9687faebf 100644 --- a/lib/datadog/core/configuration/components.rb +++ b/lib/datadog/core/configuration/components.rb @@ -15,6 +15,7 @@ require_relative '../../profiling/component' require_relative '../../appsec/component' require_relative '../../di/component' +require_relative '../../open_feature/component' require_relative '../../error_tracking/component' require_relative '../crashtracking/component' require_relative '../environment/agent_info' @@ -90,6 +91,7 @@ def build_crashtracker(settings, agent_settings, logger:) :error_tracking, :dynamic_instrumentation, :appsec, + :open_feature, :agent_info def initialize(settings) @@ -124,6 +126,7 @@ def initialize(settings) @runtime_metrics = self.class.build_runtime_metrics_worker(settings, @logger, telemetry) @health_metrics = self.class.build_health_metrics(settings, @logger, telemetry) @appsec = Datadog::AppSec::Component.build_appsec_component(settings, telemetry: telemetry) + @open_feature = OpenFeature::Component.build(settings, agent_settings, logger: @logger, telemetry: telemetry) @dynamic_instrumentation = Datadog::DI::Component.build(settings, agent_settings, @logger, telemetry: telemetry) @error_tracking = Datadog::ErrorTracking::Component.build(settings, @tracer, @logger) @environment_logger_extra[:dynamic_instrumentation_enabled] = !!@dynamic_instrumentation diff --git a/lib/datadog/core/configuration/supported_configurations.rb b/lib/datadog/core/configuration/supported_configurations.rb index b6e26ccfab4..4dbd9276b80 100644 --- a/lib/datadog/core/configuration/supported_configurations.rb +++ b/lib/datadog/core/configuration/supported_configurations.rb @@ -42,6 +42,7 @@ module Configuration "DD_ENV" => {version: ["A"]}, "DD_ERROR_TRACKING_HANDLED_ERRORS" => {version: ["A"]}, "DD_ERROR_TRACKING_HANDLED_ERRORS_INCLUDE" => {version: ["A"]}, + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED" => {version: ["A"]}, "DD_GIT_COMMIT_SHA" => {version: ["A"]}, "DD_GIT_REPOSITORY_URL" => {version: ["A"]}, "DD_HEALTH_METRICS_ENABLED" => {version: ["A"]}, diff --git a/lib/datadog/core/remote/client/capabilities.rb b/lib/datadog/core/remote/client/capabilities.rb index 0ca2e6b48d3..72254f6156a 100644 --- a/lib/datadog/core/remote/client/capabilities.rb +++ b/lib/datadog/core/remote/client/capabilities.rb @@ -3,6 +3,7 @@ require_relative '../../utils/base64' require_relative '../../../appsec/remote' require_relative '../../../tracing/remote' +require_relative '../../../open_feature/remote' module Datadog module Core @@ -38,6 +39,12 @@ def register(settings) register_receivers(Datadog::DI::Remote.receivers(@telemetry)) end + if settings.respond_to?(:open_feature) && settings.open_feature.enabled + register_capabilities(Datadog::OpenFeature::Remote.capabilities) + register_products(Datadog::OpenFeature::Remote.products) + register_receivers(Datadog::OpenFeature::Remote.receivers(@telemetry)) + end + register_capabilities(Datadog::Tracing::Remote.capabilities) register_products(Datadog::Tracing::Remote.products) register_receivers(Datadog::Tracing::Remote.receivers(@telemetry)) diff --git a/lib/datadog/open_feature.rb b/lib/datadog/open_feature.rb new file mode 100644 index 00000000000..ee560ff323f --- /dev/null +++ b/lib/datadog/open_feature.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative 'open_feature/extensions' + +module Datadog + # A namespace for the OpenFeature component. + module OpenFeature + Extensions.activate! + + def self.enabled? + Datadog.configuration.open_feature.enabled + end + + def self.engine + Datadog.send(:components).open_feature&.engine + end + end +end diff --git a/lib/datadog/open_feature/binding.rb b/lib/datadog/open_feature/binding.rb new file mode 100644 index 00000000000..d68c20b5df3 --- /dev/null +++ b/lib/datadog/open_feature/binding.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Datadog + module OpenFeature + # A namespace for binding code + module Binding + end + end +end + +require_relative 'binding/evaluator' diff --git a/lib/datadog/open_feature/binding/evaluator.rb b/lib/datadog/open_feature/binding/evaluator.rb new file mode 100644 index 00000000000..dfa23508bd2 --- /dev/null +++ b/lib/datadog/open_feature/binding/evaluator.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative 'resolution_details' + +module Datadog + module OpenFeature + module Binding + class Evaluator + def initialize(ufc_json) + # NOTE: In real binding we will parse and create Configuration + @ufc_json = ufc_json + end + + def get_assignment(_flag_key, _evaluation_context, expected_type, _time) + ResolutionDetails.new( + value: generate(expected_type), + reason: 'TARGETING_MATCH', + variant: 'hardcoded-variant', + allocation_key: 'hardcoded-allocation-key', + flag_metadata: { + 'doLog' => true, + 'allocationKey' => 'hardcoded-allocation-key' + }, + do_log: true, + extra_logging: {} + ) + end + + private + + def generate(expected_type) + case expected_type + when :boolean then true + when :string then 'hello' + when :number then 9000 + when :integer then 42 + when :float then 36.6 + when :object then [1, 2, 3] + end + end + end + end + end +end diff --git a/lib/datadog/open_feature/binding/resolution_details.rb b/lib/datadog/open_feature/binding/resolution_details.rb new file mode 100644 index 00000000000..f414bd9692a --- /dev/null +++ b/lib/datadog/open_feature/binding/resolution_details.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Datadog + module OpenFeature + # A namespace for binding code + module Binding + ResolutionDetails = Struct.new( + :value, + :reason, + :variant, + :error_code, + :error_message, + :flag_metadata, + :allocation_key, + :do_log, + :extra_logging, + keyword_init: true + ) + end + end +end diff --git a/lib/datadog/open_feature/component.rb b/lib/datadog/open_feature/component.rb new file mode 100644 index 00000000000..3442ebf7443 --- /dev/null +++ b/lib/datadog/open_feature/component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative 'evaluation_engine' + +module Datadog + module OpenFeature + # This class is the entry point for the OpenFeature component + class Component + attr_reader :telemetry, :engine + + def self.build(settings, agent_settings, logger:, telemetry:) + return unless settings.respond_to?(:open_feature) && settings.open_feature.enabled + + unless settings.respond_to?(:remote) && settings.remote.enabled + logger.warn('OpenFeature: Could not be enabled without Remote Configuration Management available') + + return + end + + new(settings, agent_settings, logger: logger, telemetry: telemetry) + rescue + Datadog.logger.warn('OpenFeature is disabled, see logged errors above') + + nil + end + + def initialize(settings, agent_settings, logger:, telemetry:) + @settings = settings + @agent_settings = agent_settings + @logger = logger + @telemetry = telemetry + + @engine = EvaluationEngine.new(telemetry, logger: logger) + end + + def shutdown! + # no-op + end + end + end +end diff --git a/lib/datadog/open_feature/configuration.rb b/lib/datadog/open_feature/configuration.rb new file mode 100644 index 00000000000..3471523b308 --- /dev/null +++ b/lib/datadog/open_feature/configuration.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative 'configuration/settings' + +module Datadog + module OpenFeature + module Configuration + end + end +end diff --git a/lib/datadog/open_feature/configuration/settings.rb b/lib/datadog/open_feature/configuration/settings.rb new file mode 100644 index 00000000000..33ceb01f31b --- /dev/null +++ b/lib/datadog/open_feature/configuration/settings.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Datadog + module OpenFeature + module Configuration + # A settings class for the OpenFeature component. + module Settings + def self.extended(base) + base = base.singleton_class unless base.is_a?(Class) + add_settings!(base) + end + + def self.add_settings!(base) + base.class_eval do + settings :open_feature do + option :enabled do |o| + o.type :bool + o.env 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED' + o.default false + end + end + end + end + end + end + end +end diff --git a/lib/datadog/open_feature/evaluation_engine.rb b/lib/datadog/open_feature/evaluation_engine.rb new file mode 100644 index 00000000000..618b9421e5d --- /dev/null +++ b/lib/datadog/open_feature/evaluation_engine.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative 'ext' +require_relative 'binding' +require_relative 'noop_evaluator' + +module Datadog + module OpenFeature + # This class performs the evaluation of the feature flag + class EvaluationEngine + attr_accessor :configuration + + ALLOWED_TYPES = %i[boolean string number float integer object].freeze + + def initialize(telemetry, logger: Datadog.logger) + @telemetry = telemetry + @logger = logger + + @mutex = Mutex.new + @evaluator = NoopEvaluator.new(nil) + @configuration = nil + end + + def fetch_value(flag_key:, expected_type:, evaluation_context: nil) + unless ALLOWED_TYPES.include?(expected_type) + message = "unknown type #{expected_type.inspect}, allowed types #{ALLOWED_TYPES.join(', ')}" + + return Binding::ResolutionDetails.new( + error_code: Ext::UNKNOWN_TYPE, error_message: message, reason: Ext::ERROR + ) + end + + # NOTE: https://github.com/open-feature/ruby-sdk-contrib/blob/main/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb#L17 + # In the example from the OpenFeature there is zero trust to the result of the evaluation + # do we want to go that way? + + @evaluator.get_assignment(flag_key, evaluation_context, expected_type, Time.now.utc.to_i) + rescue => e + @telemetry.report(e, description: 'OpenFeature: Failed to fetch value for flag') + + Binding::ResolutionDetails.new( + error_code: Ext::PROVIDER_FATAL, error_message: e.message, reason: Ext::ERROR + ) + end + + def reconfigure! + @logger.debug('OpenFeature: Removing configuration') if @configuration.nil? + + klass = @configuration.nil? ? NoopEvaluator : Binding::Evaluator + @mutex.synchronize { @evaluator = klass.new(@configuration) } + rescue => e + error_message = 'OpenFeature: Failed to reconfigure, reverting to the previous configuration' + + @logger.error("#{error_message}, error #{e.inspect}") + @telemetry.report(e, description: error_message) + end + end + end +end diff --git a/lib/datadog/open_feature/ext.rb b/lib/datadog/open_feature/ext.rb new file mode 100644 index 00000000000..e325d7ea338 --- /dev/null +++ b/lib/datadog/open_feature/ext.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Datadog + module OpenFeature + module Ext + ERROR = 'ERROR' + INITIALIZING = 'INITIALIZING' + UNKNOWN_TYPE = 'UNKNOWN_TYPE' + PROVIDER_FATAL = 'PROVIDER_FATAL' + PROVIDER_NOT_READY = 'PROVIDER_NOT_READY' + end + end +end diff --git a/lib/datadog/open_feature/extensions.rb b/lib/datadog/open_feature/extensions.rb new file mode 100644 index 00000000000..50f23b0f44c --- /dev/null +++ b/lib/datadog/open_feature/extensions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative '../core/configuration' +require_relative 'configuration' + +module Datadog + module OpenFeature + module Extensions + def self.activate! + Core::Configuration::Settings.extend(Configuration::Settings) + end + end + end +end diff --git a/lib/datadog/open_feature/noop_evaluator.rb b/lib/datadog/open_feature/noop_evaluator.rb new file mode 100644 index 00000000000..c5f24724973 --- /dev/null +++ b/lib/datadog/open_feature/noop_evaluator.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'ext' +require_relative 'binding/resolution_details' + +module Datadog + module OpenFeature + # This class is a noop interface of evaluation logic + class NoopEvaluator + def initialize(_configuration) + # no-op + end + + def get_assignment(_flag_key, _evaluation_context, _expected_type, _timestamp) + Binding::ResolutionDetails.new( + error_code: Ext::PROVIDER_NOT_READY, + error_message: 'Waiting for universal flag configuration', + reason: Ext::INITIALIZING, + flag_metadata: {}, + extra_logging: {}, + do_log: false + ) + end + end + end +end diff --git a/lib/datadog/open_feature/provider.rb b/lib/datadog/open_feature/provider.rb new file mode 100644 index 00000000000..05b327265b1 --- /dev/null +++ b/lib/datadog/open_feature/provider.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require_relative 'ext' +require 'open_feature/sdk' + +module Datadog + module OpenFeature + # Example + # + # require 'open_feature/sdk' + # require 'datadog/open_feature/provider' + # + # Datadog.configure do |config| + # config.open_feature.enabled = true + # end + # + # OpenFeature::SDK.configure do |config| + # config.set_provider(Datadog::OpenFeature::Provider.new) + # end + # + # client = OpenFeature::SDK.build_client + # client.fetch_string_value(flag_key: 'banner', default_value: 'default') + class Provider + NAME = 'Datadog Feature Flagging Provider' + + attr_reader :metadata + + def initialize + @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: NAME).freeze + end + + def init + # no-op + end + + def shutdown + # no-op + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key, default_value: default_value, expected_type: :boolean, evaluation_context: evaluation_context) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key, default_value: default_value, expected_type: :string, evaluation_context: evaluation_context) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key, default_value: default_value, expected_type: :number, evaluation_context: evaluation_context) + end + + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key, default_value: default_value, expected_type: :integer, evaluation_context: evaluation_context) + end + + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key, default_value: default_value, expected_type: :float, evaluation_context: evaluation_context) + end + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + evaluate(flag_key, default_value: default_value, expected_type: :object, evaluation_context: evaluation_context) + end + + private + + def evaluate(flag_key, default_value:, expected_type:, evaluation_context:) + engine = OpenFeature.engine + return component_not_configured_default(default_value) if engine.nil? + + result = engine.fetch_value( + flag_key: flag_key, + expected_type: expected_type, + evaluation_context: evaluation_context + ) + + if result.error_code + return ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: result.error_code, + error_message: result.error_message, + reason: result.reason + ) + end + + ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: result.value, + variant: result.variant, + reason: result.reason, + flag_metadata: result.flag_metadata + ) + end + + def component_not_configured_default(value) + ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: value, + error_code: Ext::PROVIDER_FATAL, + error_message: "Datadog's OpenFeature component must be configured", + reason: Ext::ERROR + ) + end + end + end +end diff --git a/lib/datadog/open_feature/remote.rb b/lib/datadog/open_feature/remote.rb new file mode 100644 index 00000000000..71197c2a8a0 --- /dev/null +++ b/lib/datadog/open_feature/remote.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative '../core/remote/dispatcher' + +module Datadog + module OpenFeature + module Remote + ReadError = Class.new(StandardError) + + class << self + FFE_FLAG_CONFIGURATION_RULES = 1 << 46 + FFE_PRODUCTS = ['FFE_FLAGS'].freeze + FFE_CAPABILITIES = [FFE_FLAG_CONFIGURATION_RULES].freeze + + def capabilities + FFE_CAPABILITIES + end + + def products + FFE_PRODUCTS + end + + def receivers(telemetry) + matcher = Core::Remote::Dispatcher::Matcher::Product.new(FFE_PRODUCTS) + receiver = Core::Remote::Dispatcher::Receiver.new(matcher) do |repository, changes| + engine = OpenFeature.engine + break unless engine + + changes.each do |change| + content = repository[change.path] + + unless content || change.type == :delete + next telemetry.error("OpenFeature: RemoteConfig change is not present on #{change.type}") + end + + case change.type + when :insert, :update + # @type var content: Core::Remote::Configuration::Content + engine.configuration = read_content(content) + content.applied + when :delete + # NOTE: For now, we treat deletion as clearing the configuration + # In a multi-config scenario, we might track configs per path + engine.configuration = nil + end + end + + engine.reconfigure! + end + + [receiver] + end + + private + + def read_content(content) + data = content.data.read + content.data.rewind + + raise ReadError, 'EOF reached' if data.nil? + + data + end + end + end + end +end diff --git a/sig/datadog/core/configuration/settings.rbs b/sig/datadog/core/configuration/settings.rbs index d8af1bf4b72..73a760c7550 100644 --- a/sig/datadog/core/configuration/settings.rbs +++ b/sig/datadog/core/configuration/settings.rbs @@ -118,6 +118,10 @@ module Datadog def text: () -> ::String end + interface _OpenFeature + def enabled: () -> bool + end + def initialize: (*untyped _) -> untyped def env: -> String? @@ -139,6 +143,8 @@ module Datadog def remote: (?untyped? options) -> Datadog::Core::Configuration::Settings::_Remote def error_tracking: () -> Datadog::Core::Configuration::Settings::_ErrorTracking + + def open_feature: () -> _OpenFeature end end end diff --git a/sig/datadog/open_feature.rbs b/sig/datadog/open_feature.rbs new file mode 100644 index 00000000000..410bf5c855a --- /dev/null +++ b/sig/datadog/open_feature.rbs @@ -0,0 +1,7 @@ +module Datadog + module OpenFeature + def self.enabled?: () -> bool + + def self.engine: () -> EvaluationEngine? + end +end diff --git a/sig/datadog/open_feature/binding.rbs b/sig/datadog/open_feature/binding.rbs new file mode 100644 index 00000000000..beb9cdc9005 --- /dev/null +++ b/sig/datadog/open_feature/binding.rbs @@ -0,0 +1,6 @@ +module Datadog + module OpenFeature + module Binding + end + end +end diff --git a/sig/datadog/open_feature/binding/evaluator.rbs b/sig/datadog/open_feature/binding/evaluator.rbs new file mode 100644 index 00000000000..d52e6ec7f5f --- /dev/null +++ b/sig/datadog/open_feature/binding/evaluator.rbs @@ -0,0 +1,22 @@ +module Datadog + module OpenFeature + module Binding + class Evaluator + @ufc_json: ::String? + + def initialize: (::String? ufc_json) -> void + + def get_assignment: ( + ::String flag_key, + ::OpenFeature::SDK::EvaluationContext? evaluation_context, + ::Symbol expected_type, + ::Integer time + ) -> ResolutionDetails + + private + + def generate: (::Symbol expected_type) -> untyped + end + end + end +end diff --git a/sig/datadog/open_feature/binding/resolution_details.rbs b/sig/datadog/open_feature/binding/resolution_details.rbs new file mode 100644 index 00000000000..160f5e9a15d --- /dev/null +++ b/sig/datadog/open_feature/binding/resolution_details.rbs @@ -0,0 +1,37 @@ +module Datadog + module OpenFeature + module Binding + class ResolutionDetails < ::Struct[untyped] + attr_accessor value: untyped + + attr_accessor reason: ::String? + + attr_accessor variant: ::String? + + attr_accessor error_code: ::String? + + attr_accessor error_message: ::String? + + attr_accessor flag_metadata: ::Hash[::String, untyped]? + + attr_accessor allocation_key: ::String? + + attr_accessor do_log: bool? + + attr_accessor extra_logging: ::Hash[::String, untyped]? + + def self.new: ( + ?value: untyped, + ?reason: ::String?, + ?variant: ::String?, + ?error_code: ::String?, + ?error_message: ::String?, + ?flag_metadata: ::Hash[::String, untyped]?, + ?allocation_key: ::String?, + ?do_log: bool?, + ?extra_logging: ::Hash[::String, untyped]? + ) -> instance + end + end + end +end diff --git a/sig/datadog/open_feature/component.rbs b/sig/datadog/open_feature/component.rbs new file mode 100644 index 00000000000..66c313e8aea --- /dev/null +++ b/sig/datadog/open_feature/component.rbs @@ -0,0 +1,35 @@ +module Datadog + module OpenFeature + class Component + @settings: Core::Configuration::Settings + + @agent_settings: Core::Configuration::AgentSettings + + @logger: Core::Logger + + @telemetry: Core::Telemetry::Component + + @engine: EvaluationEngine + + attr_reader telemetry: Core::Telemetry::Component + + attr_reader engine: EvaluationEngine + + def self.build: ( + Core::Configuration::Settings settings, + Core::Configuration::AgentSettings agent_settings, + logger: Core::Logger, + telemetry: Core::Telemetry::Component + ) -> Component? + + def initialize: ( + Core::Configuration::Settings settings, + Core::Configuration::AgentSettings agent_settings, + logger: Core::Logger, + telemetry: Core::Telemetry::Component + ) -> void + + def shutdown!: () -> void + end + end +end diff --git a/sig/datadog/open_feature/configuration.rbs b/sig/datadog/open_feature/configuration.rbs new file mode 100644 index 00000000000..0524b1b32d6 --- /dev/null +++ b/sig/datadog/open_feature/configuration.rbs @@ -0,0 +1,6 @@ +module Datadog + module AppSec + module Configuration + end + end +end diff --git a/sig/datadog/open_feature/configuration/settings.rbs b/sig/datadog/open_feature/configuration/settings.rbs new file mode 100644 index 00000000000..e1c238b027b --- /dev/null +++ b/sig/datadog/open_feature/configuration/settings.rbs @@ -0,0 +1,16 @@ +module Datadog + module OpenFeature + module Configuration + module Settings + # NOTE: This typespec if completely wrong. + # But it's the best we can do for now. + extend Core::Configuration::Base::ClassMethods + extend Core::Configuration::Options::ClassMethods + + def self.extended: (::Class | ::Module base) -> void + + def self.add_settings!: (untyped base) -> void + end + end + end +end diff --git a/sig/datadog/open_feature/evaluation_engine.rbs b/sig/datadog/open_feature/evaluation_engine.rbs new file mode 100644 index 00000000000..eec162469dc --- /dev/null +++ b/sig/datadog/open_feature/evaluation_engine.rbs @@ -0,0 +1,19 @@ +module Datadog + module OpenFeature + class EvaluationEngine + ALLOWED_TYPES: ::Array[::Symbol] + + attr_writer configuration: ::String? + + def initialize: (Core::Telemetry::Component telemetry, ?logger: Core::Logger) -> void + + def fetch_value: ( + flag_key: ::String, + expected_type: ::Symbol, + ?evaluation_context: ::OpenFeature::SDK::EvaluationContext + ) -> ::Hash[::Symbol, untyped] + + def reconfigure!: () -> void + end + end +end diff --git a/sig/datadog/open_feature/ext.rbs b/sig/datadog/open_feature/ext.rbs new file mode 100644 index 00000000000..b6b5262a369 --- /dev/null +++ b/sig/datadog/open_feature/ext.rbs @@ -0,0 +1,15 @@ +module Datadog + module OpenFeature + module Ext + INITIALIZING: ::String + + ERROR: ::String + + UNKNOWN_TYPE: ::String + + PROVIDER_FATAL: ::String + + PROVIDER_NOT_READY: ::String + end + end +end diff --git a/sig/datadog/open_feature/extensions.rbs b/sig/datadog/open_feature/extensions.rbs new file mode 100644 index 00000000000..87a8ce97302 --- /dev/null +++ b/sig/datadog/open_feature/extensions.rbs @@ -0,0 +1,7 @@ +module Datadog + module OpenFeature + module Extensions + def self.activate!: () -> void + end + end +end diff --git a/sig/datadog/open_feature/noop_evaluator.rbs b/sig/datadog/open_feature/noop_evaluator.rbs new file mode 100644 index 00000000000..00c50bda874 --- /dev/null +++ b/sig/datadog/open_feature/noop_evaluator.rbs @@ -0,0 +1,15 @@ +module Datadog + module OpenFeature + class NoopEvaluator + def initialize: (::String?) -> void + + def get_assignment: ( + ::String, + ::OpenFeature::SDK::EvaluationContext?, + ::Symbol, + ::Integer + ) -> Binding::ResolutionDetails + end + end +end + diff --git a/sig/datadog/open_feature/provider.rbs b/sig/datadog/open_feature/provider.rbs new file mode 100644 index 00000000000..cdb8382ee49 --- /dev/null +++ b/sig/datadog/open_feature/provider.rbs @@ -0,0 +1,64 @@ +module Datadog + module OpenFeature + class Provider + NAME: ::String + + attr_reader metadata: ::OpenFeature::SDK::Provider::ProviderMetadata + + def initialize: () -> void + + def init: () -> void + + def shutdown: () -> void + + def fetch_boolean_value: ( + flag_key: ::String, + default_value: bool, + ?evaluation_context: ::OpenFeature::SDK::EvaluationContext + ) -> ::OpenFeature::SDK::Provider::ResolutionDetails + + def fetch_string_value: ( + flag_key: ::String, + default_value: ::String, + ?evaluation_context: ::OpenFeature::SDK::EvaluationContext + ) -> ::OpenFeature::SDK::Provider::ResolutionDetails + + def fetch_number_value: ( + flag_key: ::String, + default_value: ::Numeric, + ?evaluation_context: ::OpenFeature::SDK::EvaluationContext + ) -> ::OpenFeature::SDK::Provider::ResolutionDetails + + def fetch_integer_value: ( + flag_key: ::String, + default_value: ::Integer, + ?evaluation_context: ::OpenFeature::SDK::EvaluationContext + ) -> ::OpenFeature::SDK::Provider::ResolutionDetails + + def fetch_float_value: ( + flag_key: ::String, + default_value: ::Float, + ?evaluation_context: ::OpenFeature::SDK::EvaluationContext + ) -> ::OpenFeature::SDK::Provider::ResolutionDetails + + def fetch_object_value: ( + flag_key: ::String, + default_value: ::Array[untyped] | ::Hash[untyped, untyped], + ?evaluation_context: ::OpenFeature::SDK::EvaluationContext + ) -> ::OpenFeature::SDK::Provider::ResolutionDetails + + private + + def evaluate: ( + ::String flag_key, + default_value: ::OpenFeature::SDK::Provider::ResolutionDetails::value_t, + expected_type: ::Symbol, + evaluation_context: ::OpenFeature::SDK::EvaluationContext + ) -> ::OpenFeature::SDK::Provider::ResolutionDetails + + def component_not_configured_default: ( + ::OpenFeature::SDK::Provider::ResolutionDetails::value_t value + ) -> ::OpenFeature::SDK::Provider::ResolutionDetails + end + end +end diff --git a/sig/datadog/open_feature/remote.rbs b/sig/datadog/open_feature/remote.rbs new file mode 100644 index 00000000000..ee379a8ced7 --- /dev/null +++ b/sig/datadog/open_feature/remote.rbs @@ -0,0 +1,24 @@ +module Datadog + module OpenFeature + module Remote + class ReadError < ::StandardError + end + + FFE_FLAG_CONFIGURATION_RULES: ::Integer + + FFE_PRODUCTS: ::Array[::String] + + FFE_CAPABILITIES: ::Array[::Integer] + + def self.capabilities: () -> ::Array[::Integer] + + def self.products: () -> ::Array[::String] + + def self.receivers: (Core::Telemetry::Component telemetry) -> ::Array[Core::Remote::Dispatcher::Receiver] + + private + + def self.read_content: (Core::Remote::Configuration::Content content) -> ::String + end + end +end diff --git a/spec/datadog/open_feature/component_spec.rb b/spec/datadog/open_feature/component_spec.rb new file mode 100644 index 00000000000..1d6ed45a2fd --- /dev/null +++ b/spec/datadog/open_feature/component_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/component' + +RSpec.describe Datadog::OpenFeature::Component do + before do + allow(logger).to receive(:warn) + end + + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:settings) { Datadog::Core::Configuration::Settings.new } + let(:agent_settings) { instance_double(Datadog::Core::Configuration::AgentSettings) } + let(:logger) { instance_double(Logger) } + + describe '.build' do + subject(:component) do + described_class.build(settings, agent_settings, logger: logger, telemetry: telemetry) + end + + context 'when open_feature is enabled' do + before do + settings.open_feature.enabled = true + end + + context 'when remote configuration is enabled' do + before { settings.remote.enabled = true } + + it 'returns configured component instance' do + expect(component).to be_a(described_class) + expect(component.engine).to be_a(Datadog::OpenFeature::EvaluationEngine) + end + end + + context 'when remote configuration is disabled' do + before { settings.remote.enabled = false } + + it 'logs warning and returns nil' do + expect(logger).to receive(:warn) + .with(/Could not be enabled without Remote Configuration Management/) + + expect(component).to be_nil + end + end + + context 'when exception happens during initialization' do + before do + settings.remote.enabled = true + allow(Datadog::OpenFeature::EvaluationEngine).to receive(:new).and_raise('Error!') + end + + it 'logs warning and disables the component' do + expect(Datadog.logger).to receive(:warn).with(/OpenFeature is disabled/) + expect(component).to be_nil + end + end + end + + context 'when open_feature is not enabled' do + before { settings.open_feature.enabled = false } + + it { expect(component).to be_nil } + end + + context 'when settings does not include open_feature' do + before { allow(settings).to receive(:respond_to?).and_return(false) } + + let(:settings) { instance_double(Datadog::Core::Configuration::Settings) } + + it { expect(component).to be_nil } + end + end +end diff --git a/spec/datadog/open_feature/configuration/settings_spec.rb b/spec/datadog/open_feature/configuration/settings_spec.rb new file mode 100644 index 00000000000..a17dbe2f8b4 --- /dev/null +++ b/spec/datadog/open_feature/configuration/settings_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/configuration/settings' + +RSpec.describe Datadog::OpenFeature::Configuration::Settings do + subject(:settings) { Datadog::Core::Configuration::Settings.new } + + describe 'open_feature' do + describe '#enabled' do + subject(:enabled) { settings.open_feature.enabled } + + context 'when DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED is not defined' do + around do |example| + ClimateControl.modify('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED' => nil) { example.run } + end + + it { expect(enabled).to be(false) } + end + + context 'when DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED is defined as true' do + around do |example| + ClimateControl.modify('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED' => 'true') { example.run } + end + + it { expect(enabled).to be(true) } + end + + context 'when DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED is defined as false' do + around do |example| + ClimateControl.modify('DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED' => 'false') { example.run } + end + + it { expect(enabled).to be(false) } + end + end + + describe '#enabled=' do + context 'when set to true' do + before { settings.open_feature.enabled = true } + + it { expect(settings.open_feature.enabled).to be(true) } + end + + context 'when set to false' do + before { settings.open_feature.enabled = false } + + it { expect(settings.open_feature.enabled).to be(false) } + end + end + end +end diff --git a/spec/datadog/open_feature/evaluation_engine_spec.rb b/spec/datadog/open_feature/evaluation_engine_spec.rb new file mode 100644 index 00000000000..140ea450cf1 --- /dev/null +++ b/spec/datadog/open_feature/evaluation_engine_spec.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/evaluation_engine' + +RSpec.describe Datadog::OpenFeature::EvaluationEngine do + let(:evaluator) { described_class.new(telemetry, logger: logger) } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:logger) { instance_double(Datadog::Core::Logger) } + let(:ufc) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { "name": "test" }, + "flags": { + "test_flag": { + "key": "test", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { "key": "control", "value": "hello" } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + describe '#fetch_value' do + let(:result) { evaluator.fetch_value(flag_key: 'test', expected_type: :string) } + + context 'when binding evaluator is not ready' do + it 'returns evaluation error' do + expect(result.error_code).to eq('PROVIDER_NOT_READY') + expect(result.error_message).to eq('Waiting for universal flag configuration') + expect(result.reason).to eq('INITIALIZING') + end + end + + context 'when binding evaluator returns error' do + before do + evaluator.configuration = ufc + evaluator.reconfigure! + + allow_any_instance_of(Datadog::OpenFeature::Binding::Evaluator).to receive(:get_assignment) + .and_return(error) + end + + let(:error) do + Datadog::OpenFeature::Binding::ResolutionDetails.new( + error_code: 'PROVIDER_FATAL', + error_message: 'Ooops', + reason: 'ERROR', + flag_metadata: {}, + extra_logging: {}, + do_log: false + ) + end + + it 'returns evaluation error' do + expect(result.error_code).to eq('PROVIDER_FATAL') + expect(result.error_message).to eq('Ooops') + expect(result.reason).to eq('ERROR') + end + end + + context 'when binding evaluator raises error' do + before do + evaluator.configuration = ufc + evaluator.reconfigure! + + allow(telemetry).to receive(:report) + allow_any_instance_of(Datadog::OpenFeature::Binding::Evaluator).to receive(:get_assignment) + .and_raise(error) + end + + let(:error) { RuntimeError.new("Crash") } + + it 'returns evaluation error' do + expect(result.error_code).to eq('PROVIDER_FATAL') + expect(result.error_message).to eq('Crash') + expect(result.reason).to eq('ERROR') + end + end + + context 'when expected type not in the allowed list' do + before do + evaluator.configuration = ufc + evaluator.reconfigure! + end + + let(:result) { evaluator.fetch_value(flag_key: 'test', expected_type: :whatever) } + + it 'returns evaluation error' do + expect(result.error_code).to eq('UNKNOWN_TYPE') + expect(result.error_message).to start_with('unknown type :whatever, allowed types') + expect(result.reason).to eq('ERROR') + end + end + + context 'when binding evaluator returns resolution details' do + before do + evaluator.configuration = ufc + evaluator.reconfigure! + end + + let(:result) { evaluator.fetch_value(flag_key: 'test', expected_type: :string) } + + it 'returns resolved value' do + expect(result.value).to eq('hello') + end + end + end + + describe '#reconfigure!' do + context 'when configuration is not yet present' do + it 'does nothing and logs the issue' do + expect(logger).to receive(:debug).with(/OpenFeature: Removing configuration/) + + evaluator.reconfigure! + end + end + + context 'when binding initialization fails with exception' do + before do + evaluator.configuration = ufc + evaluator.reconfigure! + + allow(Datadog::OpenFeature::Binding::Evaluator).to receive(:new).and_raise(error) + end + + let(:error) { StandardError.new('Ooops') } + + it 'reports error to telemetry and logs it' do + expect(logger).to receive(:error).with(/Ooops/) + expect(telemetry).to receive(:report) + .with(error, description: match(/OpenFeature: Failed to reconfigure/)) + + evaluator.configuration = '{}' + expect { evaluator.reconfigure! }.not_to raise_error + end + + it 'persists previouly configured evaluator' do + allow(logger).to receive(:error) + allow(telemetry).to receive(:report) + + evaluator.configuration = '{}' + expect { evaluator.reconfigure! }.not_to change { + evaluator.fetch_value(flag_key: 'test', expected_type: :string).value + }.from('hello') + end + end + + context 'when binding initialization succeeds' do + before do + evaluator.configuration = ufc + evaluator.reconfigure! + end + + let(:new_ufc) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { "name": "test" }, + "flags": { + "test_flag": { + "key": "test", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { "key": "control", "value": "goodbye" } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + xit 'reconfigures binding evaluator with new flags configuration' do + expect { evaluator.configuration = new_ufc; evaluator.reconfigure!} + .to change { evaluator.fetch_value(flag_key: 'test', expected_type: :string).value } + .from('hello').to('goodbye') + end + end + end + + xdescribe 'Evaluation logic' do + describe 'boolean' do + end + + describe 'string' do + end + + describe 'number' do + end + + describe 'object' do + end + end +end diff --git a/spec/datadog/open_feature/noop_evaluator_spec.rb b/spec/datadog/open_feature/noop_evaluator_spec.rb new file mode 100644 index 00000000000..71646e866bb --- /dev/null +++ b/spec/datadog/open_feature/noop_evaluator_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/noop_evaluator' + +RSpec.describe Datadog::OpenFeature::NoopEvaluator do + subject(:evaluator) { described_class.new(nil) } + + describe '#get_assignment' do + let(:result) { evaluator.get_assignment('flag', nil, :string, Time.now.utc.to_i) } + + it 'returns provider not ready result' do + expect(result.do_log).to be(false) + expect(result.error_code).to eq('PROVIDER_NOT_READY') + expect(result.error_message).to eq('Waiting for universal flag configuration') + expect(result.reason).to eq('INITIALIZING') + end + end +end + diff --git a/spec/datadog/open_feature/provider_spec.rb b/spec/datadog/open_feature/provider_spec.rb new file mode 100644 index 00000000000..6957cd01589 --- /dev/null +++ b/spec/datadog/open_feature/provider_spec.rb @@ -0,0 +1,363 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/provider' +require 'datadog/open_feature/evaluation_engine' + +RSpec.describe Datadog::OpenFeature::Provider do + before do + allow(telemetry).to receive(:report) + allow(Datadog::OpenFeature).to receive(:engine).and_return(engine) + end + + let(:engine) { Datadog::OpenFeature::EvaluationEngine.new(telemetry) } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + + subject(:provider) { described_class.new } + + describe '#fetch_boolean_value' do + context 'when engine is not configured' do + before { allow(Datadog::OpenFeature).to receive(:engine).and_return(nil) } + + it 'returns default value with error details' do + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false) + + expect(result.value).to eq(false) + expect(result.error_message).to match(/OpenFeature component must be configured/) + end + end + + context 'when engine is configured' do + before do + engine.configuration = ufc + engine.reconfigure! + end + + let(:result) { provider.fetch_boolean_value(flag_key: 'flag', default_value: false) } + let(:ufc) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "flags": { + "boolean_flag": { + "key": "flag", + "enabled": true, + "variationType": "BOOLEAN", + "variations": { + "control": { "key": "control", "value": true } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + it 'returns flag result details' do + expect(result.value).to be(true) + expect(result.reason).to eq('TARGETING_MATCH') + end + end + end + + describe '#fetch_string_value' do + context 'when engine is not configured' do + before { allow(Datadog::OpenFeature).to receive(:engine).and_return(nil) } + + it 'returns default value with error details' do + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + + expect(result.value).to eq('default') + expect(result.error_message).to match(/OpenFeature component must be configured/) + end + end + + context 'when engine is configured' do + before do + engine.configuration = ufc + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_string_value(flag_key: 'flag', default_value: 'default') } + let(:ufc) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "flags": { + "string_flag": { + "key": "flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { "key": "control", "value": "hello" } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + it 'returns flag result details' do + expect(result.value).to eq('hello') + expect(result.reason).to eq('TARGETING_MATCH') + end + end + end + + describe '#fetch_number_value' do + context 'when engine is not configured' do + before { allow(Datadog::OpenFeature).to receive(:engine).and_return(nil) } + + it 'returns default value with error details' do + result = provider.fetch_number_value(flag_key: 'flag', default_value: 0) + + expect(result.value).to eq(0) + expect(result.error_message).to match(/OpenFeature component must be configured/) + end + end + + context 'when engine is configured' do + before do + engine.configuration = ufc + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_number_value(flag_key: 'flag', default_value: 0) } + let(:ufc) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "flags": { + "number_flag": { + "key": "flag", + "enabled": true, + "variationType": "NUMBER", + "variations": { + "control": { "key": "control", "value": 1000 } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + it 'returns flag result details' do + expect(result.value).to eq(9000) + expect(result.reason).to eq('TARGETING_MATCH') + end + end + end + + describe '#fetch_integer_value' do + context 'when engine is not configured' do + before { allow(Datadog::OpenFeature).to receive(:engine).and_return(nil) } + + it 'returns default value with error details' do + result = provider.fetch_integer_value(flag_key: 'flag', default_value: 1) + + expect(result.value).to eq(1) + expect(result.error_message).to match(/OpenFeature component must be configured/) + end + end + + context 'when engine is configured' do + before do + engine.configuration = ufc + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_integer_value(flag_key: 'flag', default_value: 1) } + let(:ufc) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "flags": { + "integer_flag": { + "key": "flag", + "enabled": true, + "variationType": "INTEGER", + "variations": { + "control": { "key": "control", "value": 21 } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + it 'returns flag result details' do + expect(result.value).to eq(42) + expect(result.reason).to eq('TARGETING_MATCH') + end + end + end + + describe '#fetch_float_value' do + context 'when engine is not configured' do + before { allow(Datadog::OpenFeature).to receive(:engine).and_return(nil) } + + it 'returns default value with error details' do + result = provider.fetch_float_value(flag_key: 'flag', default_value: 0.0) + + expect(result.value).to eq(0.0) + expect(result.error_message).to match(/OpenFeature component must be configured/) + end + end + + context 'when engine is configured' do + before do + engine.configuration = ufc + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_float_value(flag_key: 'flag', default_value: 0.0) } + let(:ufc) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "flags": { + "float_flag": { + "key": "flag", + "enabled": true, + "variationType": "FLOAT", + "variations": { + "control": { "key": "control", "value": 12.5 } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + it 'returns flag result details' do + expect(result.value).to eq(36.6) + expect(result.reason).to eq('TARGETING_MATCH') + end + end + end + + describe '#fetch_object_value' do + context 'when engine is not configured' do + before { allow(Datadog::OpenFeature).to receive(:engine).and_return(nil) } + + it 'returns default value with error details' do + result = provider.fetch_object_value(flag_key: 'flag', default_value: { 'default' => true }) + + expect(result.value).to eq({ 'default' => true }) + expect(result.error_message).to match(/OpenFeature component must be configured/) + end + end + + context 'when engine is configured' do + before do + engine.configuration = ufc + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_object_value(flag_key: 'flag', default_value: { 'default' => true }) } + let(:ufc) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "flags": { + "object_flag": { + "key": "flag", + "enabled": true, + "variationType": "OBJECT", + "variations": { + "control": { "key": "control", "value": { "key": "value" } } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + it 'returns flag result details' do + expect(result.value).to eq([1, 2, 3]) + expect(result.reason).to eq('TARGETING_MATCH') + end + end + end +end diff --git a/spec/datadog/open_feature/remote_spec.rb b/spec/datadog/open_feature/remote_spec.rb new file mode 100644 index 00000000000..7110f18da59 --- /dev/null +++ b/spec/datadog/open_feature/remote_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_litral: true + +require 'spec_helper' +require 'datadog/open_feature/remote' +require 'datadog/core/remote/configuration/repository' + +RSpec.describe Datadog::OpenFeature::Remote do + let(:remote) { described_class } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:receivers) { remote.receivers(telemetry) } + let(:receiver) { receivers[0] } + + describe '.capabilities' do + it { expect(remote.capabilities).to eq([70368744177664]) } + end + + describe '.products' do + it { expect(remote.products).to eq(['FFE_FLAGS']) } + end + + describe '.receivers' do + it 'returns receivers' do + expect(receivers).to have(1).element + expect(receiver).to be_a(Datadog::Core::Remote::Dispatcher::Receiver) + end + + it 'matches FFE_FLAGS product paths' do + path = Datadog::Core::Remote::Configuration::Path.parse('datadog/1/FFE_FLAGS/ufc-test/config') + + expect(receiver.match?(path)).to be(true) + end + end + + describe 'receiver logic' do + before do + allow(telemetry).to receive(:error) + allow(Datadog::OpenFeature).to receive(:engine).and_return(engine) + end + + let(:engine) { Datadog::OpenFeature::EvaluationEngine.new(telemetry) } + let(:repository) { Datadog::Core::Remote::Configuration::Repository.new } + let(:target) do + Datadog::Core::Remote::Configuration::Target.parse( + { + 'custom' => {'v' => 1}, + 'hashes' => {'sha256' => Digest::SHA256.hexdigest(content_data)}, + 'length' => content_data.length + } + ) + end + let(:content) do + Datadog::Core::Remote::Configuration::Content.parse( + { + path: 'datadog/1/FFE_FLAGS/latest/config', + content: StringIO.new(content_data) + } + ) + end + let(:content_data) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { "name": "test" }, + "flags": { + "test_flag": { + "key": "test_flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "control": { "key": "control", "value": "control_value" } + }, + "allocations": [ + { + "key": "rollout", + "splits": [{ "variationKey": "control", "shards": [] }], + "doLog": false + } + ] + } + } + } + } + } + JSON + end + + context 'when change type is insert' do + let(:transaction) do + repository.transaction { |_, t| t.insert(content.path, target, content) } + end + + it 'reconfigures engine and acknowledges applied change' do + expect(engine).to receive(:reconfigure!) + + receiver.call(repository, transaction) + + expect(engine.configuration).to eq(content_data) + expect(content.apply_state).to eq(Datadog::Core::Remote::Configuration::Content::ApplyState::ACKNOWLEDGED) + end + end + + context 'when change type is update' do + before do + txn = repository.transaction { |_, t| t.insert(content.path, target, content) } + receiver.call(repository, txn) + end + + let(:transaction) do + repository.transaction { |_, t| t.update(new_content.path, target, new_content) } + end + let(:new_content) do + Datadog::Core::Remote::Configuration::Content.parse( + {path: content.path.to_s, content: StringIO.new(new_content_data)} + ) + end + let(:new_content_data) do + <<~JSON + { + "data": { + "type": "universal-flag-configuration", + "id": "1", + "attributes": { + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { "name": "test" }, + "flags": {} + } + } + } + JSON + end + + it 'reconfigures engine and acknowledges applied change' do + expect(engine).to receive(:reconfigure!) + + receiver.call(repository, transaction) + + expect(engine.configuration).to eq(new_content_data) + expect(content.apply_state).to eq(Datadog::Core::Remote::Configuration::Content::ApplyState::ACKNOWLEDGED) + end + end + + context 'when change type is delete' do + before do + repository.transaction { |_r, t| t.insert(content.path, target, content) } + end + + let(:transaction) do + repository.transaction { |_, t| t.delete(content.path) } + end + + it 'performs no-op on delete but reconfigures' do + expect(engine).to receive(:reconfigure!) + expect { receiver.call(repository, transaction) }.not_to raise_error + end + end + + context 'when content is missing' do + let(:changes) do + [ + instance_double( + Datadog::Core::Remote::Configuration::Repository::Change::Updated, + path: missing_path, + type: :update, + ) + ] + end + let(:missing_path) do + Datadog::Core::Remote::Configuration::Path.parse('datadog/1/FFE_FLAGS/other/config') + end + + it 'logs error when content missing and still reconfigures' do + expect(telemetry).to receive(:error).with(/OpenFeature: RemoteConfig change is not present/) + expect(engine).to receive(:reconfigure!) + + receiver.call(repository, changes) + end + end + end +end diff --git a/spec/datadog/open_feature_spec.rb b/spec/datadog/open_feature_spec.rb new file mode 100644 index 00000000000..81b3f76f7ee --- /dev/null +++ b/spec/datadog/open_feature_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature' + +RSpec.describe Datadog::OpenFeature do + describe '.enabled?' do + context 'when OpenFeature is disabled' do + around do |example| + Datadog.configure { |c| c.open_feature.enabled = false } + example.run + ensure + Datadog.configuration.reset! + end + + it { expect(described_class.enabled?).to be(false) } + end + + context 'when OpenFeature is enabled' do + around do |example| + Datadog.configure { |c| c.open_feature.enabled = true } + example.run + ensure + Datadog.configuration.reset! + end + + it { expect(described_class.enabled?).to be(true) } + end + end + + describe '.engine' do + context 'when component is not available' do + around do |example| + Datadog.configure { |c| c.open_feature.enabled = false } + example.run + ensure + Datadog.configuration.reset! + end + + it { expect(described_class.engine).to be_nil } + end + + context 'when component is available' do + around do |example| + Datadog.configure { |c| c.open_feature.enabled = true } + example.run + ensure + Datadog.configuration.reset! + end + + it { expect(described_class.engine).to be_a(Datadog::OpenFeature::EvaluationEngine) } + end + end +end diff --git a/supported-configurations.json b/supported-configurations.json index 6a75b00cd55..b8268470fc6 100644 --- a/supported-configurations.json +++ b/supported-configurations.json @@ -106,6 +106,9 @@ "DD_ERROR_TRACKING_HANDLED_ERRORS_INCLUDE": { "version": ["A"] }, + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": { + "version": ["A"] + }, "DD_GIT_COMMIT_SHA": { "version": ["A"] }, diff --git a/vendor/rbs/openfeature-sdk/0/openfeature-sdk.rbs b/vendor/rbs/openfeature-sdk/0/openfeature-sdk.rbs new file mode 100644 index 00000000000..957b4069828 --- /dev/null +++ b/vendor/rbs/openfeature-sdk/0/openfeature-sdk.rbs @@ -0,0 +1,37 @@ +module OpenFeature + module SDK + class EvaluationContext + end + + module Provider + class ProviderMetadata + def initialize: (name: ::String) -> void + end + + class ResolutionDetails < ::Struct[untyped] + type value_t = bool | ::String | ::Numeric | ::Integer | ::Float | ::Array[untyped] | ::Hash[untyped, untyped] + + def self.new: ( + ?value: value_t, + ?variant: untyped, + ?flag_metadata: untyped, + ?error_code: ErrorCode::t, + ?error_message: ::String, + ?reason: Reason::t + ) -> instance + end + + module ErrorCode + type t = ::String + + PROVIDER_FATAL: t + end + + module Reason + type t = ::String + + ERROR: t + end + end + end +end