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/appraisal/ruby-3.4.rb b/appraisal/ruby-3.4.rb index 92a5a6448ee..177a35f2c61 100644 --- a/appraisal/ruby-3.4.rb +++ b/appraisal/ruby-3.4.rb @@ -187,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/lib/datadog.rb b/lib/datadog.rb index 224756cb4df..4419c2e68b7 100644 --- a/lib/datadog.rb +++ b/lib/datadog.rb @@ -9,6 +9,7 @@ require_relative 'datadog/appsec' require_relative 'datadog/di' require_relative 'datadog/data_streams' +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 0ea3a1dabe2..66589d997e1 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' @@ -106,7 +107,8 @@ def build_data_streams(settings, agent_settings, logger) :dynamic_instrumentation, :appsec, :agent_info, - :data_streams + :data_streams, + :open_feature def initialize(settings) @settings = settings @@ -140,6 +142,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) @data_streams = self.class.build_data_streams(settings, agent_settings, @logger) @@ -199,6 +202,9 @@ def shutdown!(replacement = nil) # Shutdown DI after remote, since remote config triggers DI operations. dynamic_instrumentation&.shutdown! + # Shutdown OpenFeature component + open_feature&.shutdown! + # Decommission AppSec appsec&.shutdown! diff --git a/lib/datadog/core/configuration/supported_configurations.rb b/lib/datadog/core/configuration/supported_configurations.rb index 707787510d1..dd8912b30be 100644 --- a/lib/datadog/core/configuration/supported_configurations.rb +++ b/lib/datadog/core/configuration/supported_configurations.rb @@ -43,6 +43,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..2e1fd88e6b3 --- /dev/null +++ b/lib/datadog/open_feature.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_relative 'core/configuration' +require_relative 'open_feature/configuration' + +module Datadog + # A namespace for the OpenFeature component. + module OpenFeature + Core::Configuration::Settings.extend(Configuration::Settings) + + 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/component.rb b/lib/datadog/open_feature/component.rb new file mode 100644 index 00000000000..7f4ad63d1b1 --- /dev/null +++ b/lib/datadog/open_feature/component.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative 'evaluation_engine' +require_relative 'exposures/buffer' +require_relative 'exposures/worker' +require_relative 'exposures/deduplicator' +require_relative 'exposures/reporter' +require_relative 'transport/http' + +module Datadog + module OpenFeature + # This class is the entry point for the OpenFeature component + class Component + attr_reader :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 + message = 'OpenFeature could not be enabled as Remote Configuration is currently disabled. ' \ + 'To enable Remote Configuration, see https://docs.datadoghq.com/agent/remote_config' + logger.warn(message) + + return + end + + new(settings, agent_settings, logger: logger, telemetry: telemetry) + end + + def initialize(settings, agent_settings, logger:, telemetry:) + transport = Transport::HTTP.build(agent_settings: agent_settings, logger: logger) + @worker = Exposures::Worker.new(settings: settings, transport: transport, telemetry: telemetry, logger: logger) + + reporter = Exposures::Reporter.new(@worker, telemetry: telemetry, logger: logger) + @engine = EvaluationEngine.new(reporter, telemetry: telemetry, logger: logger) + end + + def shutdown! + @worker.graceful_shutdown + 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..33ceb01f31b --- /dev/null +++ b/lib/datadog/open_feature/configuration.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..8062f55587b --- /dev/null +++ b/lib/datadog/open_feature/evaluation_engine.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative 'ext' +require_relative 'noop_evaluator' +require_relative 'resolution_details' + +module Datadog + module OpenFeature + # This class performs the evaluation of the feature flag + class EvaluationEngine + ReconfigurationError = Class.new(StandardError) + + ALLOWED_TYPES = %w[boolean string number float integer object].freeze + + def initialize(reporter, telemetry:, logger:) + @reporter = reporter + @telemetry = telemetry + @logger = logger + + @evaluator = NoopEvaluator.new(nil) + end + + def fetch_value(flag_key:, default_value:, expected_type:, evaluation_context: nil) + unless ALLOWED_TYPES.include?(expected_type) + message = "unknown type #{expected_type.inspect}, allowed types #{ALLOWED_TYPES.join(", ")}" + return ResolutionDetails.build_error( + value: default_value, error_code: Ext::UNKNOWN_TYPE, error_message: message + ) + end + + context = evaluation_context&.fields || {} + result = @evaluator.get_assignment(flag_key, default_value, context, expected_type) + + @reporter.report(result, flag_key: flag_key, context: evaluation_context) + + result + rescue => e + @telemetry.report(e, description: 'OpenFeature: Failed to fetch flag value') + + ResolutionDetails.build_error( + value: default_value, error_code: Ext::PROVIDER_FATAL, error_message: e.message + ) + end + + def reconfigure!(configuration) + @logger.debug('OpenFeature: Removing configuration') if configuration.nil? + + @evaluator = NoopEvaluator.new(configuration) + rescue => e + message = 'OpenFeature: Failed to reconfigure, reverting to the previous configuration' + + @logger.error("#{message}, error #{e.inspect}") + @telemetry.report(e, description: message) + + raise ReconfigurationError, e.message + end + end + end +end diff --git a/lib/datadog/open_feature/exposures/batch_builder.rb b/lib/datadog/open_feature/exposures/batch_builder.rb new file mode 100644 index 00000000000..d6647b4aca2 --- /dev/null +++ b/lib/datadog/open_feature/exposures/batch_builder.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Datadog + module OpenFeature + module Exposures + # This class builds a batch of exposures and context to be sent to the Agent + class BatchBuilder + def initialize(settings) + @context = build_context(settings) + end + + def payload_for(events) + { + context: @context, + exposures: events + } + end + + private + + def build_context(settings) + context = {} + context[:env] = settings.env if settings.env + context[:service] = settings.service if settings.service + context[:version] = settings.version if settings.version + + context + end + end + end + end +end diff --git a/lib/datadog/open_feature/exposures/buffer.rb b/lib/datadog/open_feature/exposures/buffer.rb new file mode 100644 index 00000000000..094ca3b27b6 --- /dev/null +++ b/lib/datadog/open_feature/exposures/buffer.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative '../../core/buffer/cruby' + +module Datadog + module OpenFeature + module Exposures + # This class is a buffer for exposure events that evicts at random and + # keeps track of the number of dropped events + # + # WARNING: This class does not work as intended on JRuby + class Buffer < Core::Buffer::CRuby + DEFAULT_LIMIT = 1_000 + + attr_reader :dropped_count + + def initialize(limit = DEFAULT_LIMIT) + @dropped = 0 + @dropped_count = 0 + + super + end + + protected + + def drain! + drained = super + + @dropped_count = @dropped + @dropped = 0 + + drained + end + + def replace!(item) + @dropped += 1 + + super + end + end + end + end +end diff --git a/lib/datadog/open_feature/exposures/deduplicator.rb b/lib/datadog/open_feature/exposures/deduplicator.rb new file mode 100644 index 00000000000..d9a53373bf1 --- /dev/null +++ b/lib/datadog/open_feature/exposures/deduplicator.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative '../../core/utils/lru_cache' + +module Datadog + module OpenFeature + module Exposures + # This class is a deduplication buffer based on LRU cache for exposure events + class Deduplicator + DEFAULT_CACHE_LIMIT = 1_000 + + def initialize(limit: DEFAULT_CACHE_LIMIT) + @cache = Datadog::Core::Utils::LRUCache.new(limit) + @mutex = Mutex.new + end + + def duplicate?(key, value) + @mutex.synchronize do + stored = @cache[key] + return true if stored == value + + @cache[key] = value + end + + false + end + end + end + end +end diff --git a/lib/datadog/open_feature/exposures/event.rb b/lib/datadog/open_feature/exposures/event.rb new file mode 100644 index 00000000000..53f2b81957f --- /dev/null +++ b/lib/datadog/open_feature/exposures/event.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative '../../core/utils/time' + +module Datadog + module OpenFeature + module Exposures + # A data model for an exposure event + module Event + TARGETING_KEY_FIELD = 'targeting_key' + ALLOWED_FIELD_TYPES = [String, Integer, Float, TrueClass, FalseClass].freeze + + class << self + def cache_key(result, flag_key:, context:) + "#{flag_key}:#{context.targeting_key}" + end + + def cache_value(result, flag_key:, context:) + "#{result.allocation_key}:#{result.variant}" + end + + def build(result, flag_key:, context:) + { + timestamp: current_timestamp_ms, + allocation: { + key: result.allocation_key + }, + flag: { + key: flag_key + }, + variant: { + key: result.variant + }, + subject: { + id: context.targeting_key, + attributes: extract_attributes(context) + } + }.freeze + end + + private + + # NOTE: We take all filds of the context that does not support nesting + # and will ignore targeting key as it will be set as `subject.id` + def extract_attributes(context) + context.fields.select do |key, value| + next false if key == TARGETING_KEY_FIELD + + ALLOWED_FIELD_TYPES.include?(value.class) + end + end + + def current_timestamp_ms + (Datadog::Core::Utils::Time.now.to_f * 1000).to_i + end + end + end + end + end +end diff --git a/lib/datadog/open_feature/exposures/reporter.rb b/lib/datadog/open_feature/exposures/reporter.rb new file mode 100644 index 00000000000..82761b9a0c5 --- /dev/null +++ b/lib/datadog/open_feature/exposures/reporter.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative 'event' +require_relative 'deduplicator' + +module Datadog + module OpenFeature + module Exposures + # This class is responsible for reporting exposures to the Agent + class Reporter + def initialize(worker, telemetry:, logger:) + @worker = worker + @logger = logger + @telemetry = telemetry + @deduplicator = Deduplicator.new + end + + # NOTE: Reporting expects evaluation context to be always present, but it + # might be missing depending on the customer way of using flags evaluation API. + # In addition to that the evaluation result must be marked for reporting. + def report(result, flag_key:, context:) + return false if context.nil? + return false unless result.log? + + key = Event.cache_key(result, flag_key: flag_key, context: context) + value = Event.cache_value(result, flag_key: flag_key, context: context) + return false if @deduplicator.duplicate?(key, value) + + event = Event.build(result, flag_key: flag_key, context: context) + @worker.enqueue(event) + rescue => e + @logger.debug { "OpenFeature: Failed to report resolution details: #{e.class}: #{e.message}" } + @telemetry.report(e, description: 'OpenFeature: Failed to report resolution details') + + false + end + end + end + end +end diff --git a/lib/datadog/open_feature/exposures/worker.rb b/lib/datadog/open_feature/exposures/worker.rb new file mode 100644 index 00000000000..880a6dec7b7 --- /dev/null +++ b/lib/datadog/open_feature/exposures/worker.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require_relative '../../core/utils/time' +require_relative '../../core/workers/queue' +require_relative '../../core/workers/polling' + +require_relative 'buffer' +require_relative 'batch_builder' + +module Datadog + module OpenFeature + module Exposures + # This class is responsible for sending exposures to the Agent + class Worker + include Core::Workers::Queue + include Core::Workers::Polling + + GRACEFUL_SHUTDOWN_EXTRA_SECONDS = 5 + GRACEFUL_SHUTDOWN_WAIT_INTERVAL_SECONDS = 0.5 + + DEFAULT_FLUSH_INTERVAL_SECONDS = 30 + DEFAULT_BUFFER_LIMIT = Buffer::DEFAULT_LIMIT + + def initialize( + settings:, + transport:, + telemetry:, + logger:, + flush_interval_seconds: DEFAULT_FLUSH_INTERVAL_SECONDS, + buffer_limit: DEFAULT_BUFFER_LIMIT + ) + @logger = logger + @transport = transport + @telemetry = telemetry + @batch_builder = BatchBuilder.new(settings) + @buffer_limit = buffer_limit + + self.buffer = Buffer.new(buffer_limit) + self.fork_policy = Core::Workers::Async::Thread::FORK_POLICY_RESTART + self.loop_base_interval = flush_interval_seconds + self.enabled = true + end + + def start + return if !enabled? || running? + + perform + end + + def stop(force_stop = false, timeout = Core::Workers::Polling::DEFAULT_SHUTDOWN_TIMEOUT) + buffer.close if running? + + super + end + + def enqueue(event) + buffer.push(event) + start unless running? + + true + end + + def dequeue + [buffer.pop, buffer.dropped_count] + end + + def perform(*args) + events, dropped = args + send_events(Array(events), dropped.to_i) + end + + def graceful_shutdown + return false unless enabled? || !run_loop? + + self.enabled = false + + started = Core::Utils::Time.get_time + wait_time = loop_base_interval + GRACEFUL_SHUTDOWN_EXTRA_SECONDS + + loop do + break if buffer.empty? && !in_iteration? + + sleep(GRACEFUL_SHUTDOWN_WAIT_INTERVAL_SECONDS) + break if Core::Utils::Time.get_time - started > wait_time + end + + stop(true) + end + + private + + def send_events(events, dropped) + return if events.empty? + + if dropped.positive? + @logger.debug { "OpenFeature: Resolution details worker dropped #{dropped} event(s) due to full buffer" } + end + + payload = @batch_builder.payload_for(events) + response = @transport.send_exposures(payload) + + unless response&.ok? + @logger.debug { "OpenFeature: Resolution details upload response was not OK: #{response.inspect}" } + end + + response + rescue => e + @logger.debug { "OpenFeature: Failed to flush resolution details events: #{e.class}: #{e.message}" } + @telemetry.report(e, description: 'OpenFeature: Failed to flush resolution details events') + + nil + end + 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/noop_evaluator.rb b/lib/datadog/open_feature/noop_evaluator.rb new file mode 100644 index 00000000000..00d9d59a2d2 --- /dev/null +++ b/lib/datadog/open_feature/noop_evaluator.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'ext' +require_relative '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, default_value, _context, _expected_type) + ResolutionDetails.new( + value: default_value, + log?: false, + error?: true, + error_code: Ext::PROVIDER_NOT_READY, + error_message: 'Waiting for universal flag configuration', + reason: Ext::INITIALIZING + ) + 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..8755b668533 --- /dev/null +++ b/lib/datadog/open_feature/provider.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require_relative 'ext' +require 'open_feature/sdk' + +module Datadog + module OpenFeature + # OpenFeature feature flagging provider backed by Datadog Remote Configuration. + # + # Implementation follows the OpenFeature contract of Provider SDK. + # For details see: + # - https://github.com/open-feature/ruby-sdk/blob/v0.4.1/README.md#develop-a-provider + # - https://github.com/open-feature/ruby-sdk/blob/v0.4.1/lib/open_feature/sdk/provider/no_op_provider.rb + # + # In the example below you can see how to configure the OpenFeature SDK + # https://github.com/open-feature/ruby-sdk to use the Datadog feature flags provider. + # + # Example: + # + # Make sure to enable Remote Configuration and OpenFeature in the Datadog configuration. + # + # ```ruby + # # FILE: initializers/datadog.rb + # Datadog.configure do |config| + # config.remote.enabled = true + # config.open_feature.enabled = true + # end + # ``` + # + # And configure the OpenFeature SDK to use the Datadog feature flagging provider. + # + # ```ruby + # # FILE: initializers/open_feature.rb + # require 'open_feature/sdk' + # require 'datadog/open_feature/provider' + # + # OpenFeature::SDK.configure do |config| + # config.set_provider(Datadog::OpenFeature::Provider.new) + # end + # ``` + # + # Now you can create OpenFeature SDK client and use it to fetch feature flag values. + # + # ```ruby + # client = OpenFeature::SDK.build_client + # context = OpenFeature::SDK::EvaluationContext.new('email' => 'john.doe@gmail.com') + # + # client.fetch_string_value( + # flag_key: 'banner', default_value: 'Greetings!', evaluation_context: context + # ) + # # => 'Welcome back!' + # ``` + 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, + default_value: default_value, + expected_type: expected_type, + evaluation_context: evaluation_context + ) + + if result.error? + return ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: result.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..d7753f250fc --- /dev/null +++ b/lib/datadog/open_feature/remote.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative '../core/remote/dispatcher' + +module Datadog + module OpenFeature + # This module contains the remote configuration functionality for 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: Remote Configuration change is not present on #{change.type}") + end + + # NOTE: In the current RC implementation we immediately apply the configuration, + # but that might change if we need to apply patches instead. + case change.type + when :insert, :update + begin + # @type var content: Core::Remote::Configuration::Content + engine.reconfigure!(read_content(content)) + content.applied + rescue ReadError => e + content.errored("Error reading Remote Configuration content: #{e.message}") + rescue EvaluationEngine::ReconfigurationError => e + content.errored("Error applying OpenFeature configuration: #{e.message}") + end + when :delete + # NOTE: For now, we treat deletion as clearing the configuration + # In a multi-config scenario, we might track configs per path + engine.reconfigure!(nil) + end + end + 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/lib/datadog/open_feature/resolution_details.rb b/lib/datadog/open_feature/resolution_details.rb new file mode 100644 index 00000000000..1ef979d0f0d --- /dev/null +++ b/lib/datadog/open_feature/resolution_details.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative 'ext' + +module Datadog + module OpenFeature + # This class is based on the `OpenFeature::SDK::Provider::ResolutionDetails` class + # + # See: https://github.com/open-feature/ruby-sdk/blob/v0.4.1/lib/open_feature/sdk/provider/resolution_details.rb + class ResolutionDetails < Struct.new( + :value, + :reason, + :variant, + :error_code, + :error_message, + :flag_metadata, + :allocation_key, + :extra_logging, + :log?, + :error?, + keyword_init: true + ) + def self.build_error(value:, error_code:, error_message:, reason: Ext::ERROR) + new( + value: value, + error_code: error_code, + error_message: error_message, + reason: reason, + error?: true, + log?: false + ).freeze + end + end + end +end diff --git a/lib/datadog/open_feature/transport/exposures.rb b/lib/datadog/open_feature/transport/exposures.rb new file mode 100644 index 00000000000..03a987dc70b --- /dev/null +++ b/lib/datadog/open_feature/transport/exposures.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative '../../core/transport/parcel' +require_relative '../../core/transport/request' + +require_relative 'http/client' +require_relative 'http/exposures' + +module Datadog + module OpenFeature + module Transport + module Exposures + # Data transfer object for encoded exposure events + class EncodedParcel + include Core::Transport::Parcel + + def encode_with(encoder) + encoder.encode(data) + end + end + + # Exposure events request + class Request < Datadog::Core::Transport::Request + attr_reader :headers + + def initialize(parcel, headers = {}) + super(parcel) + @headers = headers + end + end + + # Sends exposure events based on transport API configuration + class Transport + attr_reader :client, :apis, :default_api, :logger + + def initialize(apis, default_api, logger:) + @apis = apis + @default_api = default_api + @logger = logger + + @client = HTTP::Client.new(@apis[default_api], logger: logger) + end + + def send_exposures(payload, headers: {}) + parcel = EncodedParcel.new(payload) + request = Request.new(parcel, headers) + + client.send_exposures_payload(request) + end + end + end + end + end +end diff --git a/lib/datadog/open_feature/transport/http.rb b/lib/datadog/open_feature/transport/http.rb new file mode 100644 index 00000000000..55d4debdd61 --- /dev/null +++ b/lib/datadog/open_feature/transport/http.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative '../../core/transport/http' + +require_relative 'http/api' +require_relative 'exposures' + +module Datadog + module OpenFeature + module Transport + # Namespace for HTTP transport components + module HTTP + module_function + + def build(agent_settings:, logger:, headers: nil) + Datadog::Core::Transport::HTTP.build( + api_instance_class: Exposures::API::Instance, + agent_settings: agent_settings, + logger: logger, + headers: headers + ) do |transport| + apis = API.defaults + + transport.api API::EXPOSURES, apis[API::EXPOSURES] + + yield(transport) if block_given? + end.to_transport(Datadog::OpenFeature::Transport::Exposures::Transport) + end + end + end + end +end diff --git a/lib/datadog/open_feature/transport/http/api.rb b/lib/datadog/open_feature/transport/http/api.rb new file mode 100644 index 00000000000..b62057dc04b --- /dev/null +++ b/lib/datadog/open_feature/transport/http/api.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative '../../../core/encoding' +require_relative '../../../core/transport/http/api/map' + +require_relative 'exposures' + +module Datadog + module OpenFeature + module Transport + module HTTP + # Namespace for API components + module API + EXPOSURES = 'exposures' + + module_function + + def defaults + Datadog::Core::Transport::HTTP::API::Map[ + EXPOSURES => Exposures::API::Spec.new do |spec| + spec.endpoint = Exposures::API::Endpoint.new( + '/evp_proxy/v2/api/v2/exposures', + Datadog::Core::Encoding::JSONEncoder + ) + end + ] + end + end + end + end + end +end diff --git a/lib/datadog/open_feature/transport/http/client.rb b/lib/datadog/open_feature/transport/http/client.rb new file mode 100644 index 00000000000..61f881e9028 --- /dev/null +++ b/lib/datadog/open_feature/transport/http/client.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative '../../../core/transport/http/env' +require_relative '../../../core/transport/http/response' + +module Datadog + module OpenFeature + module Transport + module HTTP + # Routes, encodes, and sends tracer data to the trace agent via HTTP. + class Client + attr_reader :api, :logger + + def initialize(api, logger:) + @api = api + @logger = logger + end + + def send_request(request) + env = build_env(request) + + yield(api, env) + rescue => e + message = "Internal error during request. Cause: #{e.class.name} #{e.message} " \ + "Location: #{Array(e.backtrace).first}" + + @logger.debug(message) + + Datadog::Core::Transport::InternalErrorResponse.new(e) + end + + private + + def build_env(request) + Datadog::Core::Transport::HTTP::Env.new(request) + end + end + end + end + end +end diff --git a/lib/datadog/open_feature/transport/http/exposures.rb b/lib/datadog/open_feature/transport/http/exposures.rb new file mode 100644 index 00000000000..96f363186b5 --- /dev/null +++ b/lib/datadog/open_feature/transport/http/exposures.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require_relative '../../../core/transport/http/api/endpoint' +require_relative '../../../core/transport/http/api/instance' +require_relative '../../../core/transport/http/api/spec' + +require_relative 'client' + +module Datadog + module OpenFeature + module Transport + module HTTP + # HTTP transport behavior for exposure events + module Exposures + # Extensions for HTTP client + module Client + def send_exposures_payload(request) + send_request(request) do |api, env| + api.send_exposures(env) + end + end + end + + module API + # HTTP API Spec + class Spec < Core::Transport::HTTP::API::Spec + attr_accessor :endpoint + + def send_exposures(env, &block) + if endpoint.nil? + raise Core::Transport::HTTP::API::Spec::EndpointNotDefinedError.new( + 'exposures', self + ) + end + + endpoint.call(env, &block) + end + end + + # HTTP API Instance + class Instance < Core::Transport::HTTP::API::Instance + def send_exposures(env) + spec.send_exposures(env) do |request_env| + call(request_env) + end + end + end + + # Endpoint for submitting exposure events data + class Endpoint < Datadog::Core::Transport::HTTP::API::Endpoint + HEADER_CONTENT_TYPE = 'Content-Type' + HEADER_SUBDOMAIN = 'X-Datadog-EVP-Subdomain' + SUBDOMAIN_VALUE = 'event-platform-intake' + + attr_reader :encoder + + def initialize(path, encoder) + super(:post, path) + @encoder = encoder + end + + def call(env, &block) + env.headers[HEADER_CONTENT_TYPE] = encoder.content_type + env.headers[HEADER_SUBDOMAIN] = SUBDOMAIN_VALUE + env.body = + if env.request.parcel.respond_to?(:encode_with) + env.request.parcel.encode_with(encoder) + else + encoder.encode(env.request.parcel.data) + end + + super + end + end + end + end + + HTTP::Client.include(Exposures::Client) + end + end + end +end diff --git a/sig/datadog/core/configuration/components.rbs b/sig/datadog/core/configuration/components.rbs index 09fac6ab469..9e14b6b6121 100644 --- a/sig/datadog/core/configuration/components.rbs +++ b/sig/datadog/core/configuration/components.rbs @@ -40,6 +40,8 @@ module Datadog attr_reader data_streams: Datadog::DataStreams::Processor? + attr_reader open_feature: Datadog::OpenFeature::Component? + def initialize: (untyped settings) -> untyped def startup!: (untyped settings) -> untyped 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/component.rbs b/sig/datadog/open_feature/component.rbs new file mode 100644 index 00000000000..c0e06a3b2fd --- /dev/null +++ b/sig/datadog/open_feature/component.rbs @@ -0,0 +1,37 @@ +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 flush: () -> 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..e1c238b027b --- /dev/null +++ b/sig/datadog/open_feature/configuration.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..1fbdaa65d0b --- /dev/null +++ b/sig/datadog/open_feature/evaluation_engine.rbs @@ -0,0 +1,21 @@ +module Datadog + module OpenFeature + class EvaluationEngine + class ReconfigurationError < StandardError + end + + ALLOWED_TYPES: ::Array[::String] + + def initialize: (Exposures::Reporter reporter, telemetry: Core::Telemetry::Component, logger: Core::Logger) -> void + + def fetch_value: ( + flag_key: ::String, + default_value: untyped, + expected_type: ::String, + ?evaluation_context: ::OpenFeature::SDK::EvaluationContext? + ) -> ResolutionDetails + + def reconfigure!: (::String? configuration) -> void + end + end +end diff --git a/sig/datadog/open_feature/exposures/batch_builder.rbs b/sig/datadog/open_feature/exposures/batch_builder.rbs new file mode 100644 index 00000000000..576e101f5d7 --- /dev/null +++ b/sig/datadog/open_feature/exposures/batch_builder.rbs @@ -0,0 +1,20 @@ +module Datadog + module OpenFeature + module Exposures + class BatchBuilder + type payload_t = ::Hash[::Symbol, untyped] + + @context: ::Hash[::Symbol, ::String] + + def initialize: (Core::Configuration::Settings settings) -> void + + def payload_for: (::Array[Event::t] events) -> payload_t + + private + + def build_context: (Core::Configuration::Settings settings) -> ::Hash[::Symbol, ::String] + end + end + end +end + diff --git a/sig/datadog/open_feature/exposures/buffer.rbs b/sig/datadog/open_feature/exposures/buffer.rbs new file mode 100644 index 00000000000..76aea4d1613 --- /dev/null +++ b/sig/datadog/open_feature/exposures/buffer.rbs @@ -0,0 +1,17 @@ +module Datadog + module OpenFeature + module Exposures + class Buffer < Core::Buffer::CRuby + DEFAULT_LIMIT: ::Integer + + attr_reader dropped_count: ::Integer + + def initialize: (?::Integer) -> void + + def drain!: () -> ::Array[Object] + + def replace!: (Object) -> Object? + end + end + end +end diff --git a/sig/datadog/open_feature/exposures/deduplicator.rbs b/sig/datadog/open_feature/exposures/deduplicator.rbs new file mode 100644 index 00000000000..448977c6b95 --- /dev/null +++ b/sig/datadog/open_feature/exposures/deduplicator.rbs @@ -0,0 +1,15 @@ +module Datadog + module OpenFeature + module Exposures + class Deduplicator + DEFAULT_CACHE_LIMIT: ::Integer + + def initialize: (?limit: ::Integer) -> void + + def duplicate?: (::String, ::String) -> bool + end + end + end +end + + diff --git a/sig/datadog/open_feature/exposures/event.rbs b/sig/datadog/open_feature/exposures/event.rbs new file mode 100644 index 00000000000..f31d4361e4f --- /dev/null +++ b/sig/datadog/open_feature/exposures/event.rbs @@ -0,0 +1,37 @@ +module Datadog + module OpenFeature + module Exposures + module Event + type t = ::Hash[::Symbol, untyped] + + ALLOWED_FIELD_TYPES: ::Array[::Class] + + TARGETING_KEY_FIELD: ::String + + def self.cache_key: ( + ResolutionDetails result, + flag_key: ::String, + context: ::OpenFeature::SDK::EvaluationContext + ) -> ::String + + def self.cache_value: ( + ResolutionDetails result, + flag_key: ::String, + context: ::OpenFeature::SDK::EvaluationContext + ) -> ::String + + def self.build: ( + ResolutionDetails result, + flag_key: ::String, + context: ::OpenFeature::SDK::EvaluationContext + ) -> t + + private + + def self.current_timestamp_ms: () -> ::Integer + + def self.extract_attributes: (::OpenFeature::SDK::EvaluationContext) -> ::Hash[::String, untyped] + end + end + end +end diff --git a/sig/datadog/open_feature/exposures/reporter.rbs b/sig/datadog/open_feature/exposures/reporter.rbs new file mode 100644 index 00000000000..46696975ff3 --- /dev/null +++ b/sig/datadog/open_feature/exposures/reporter.rbs @@ -0,0 +1,25 @@ +module Datadog + module OpenFeature + module Exposures + class Reporter + @worker: Worker + + @logger: Core::Logger + + @telemetry: Core::Telemetry::Component + + @deduplicator: Deduplicator + + def initialize: (Worker worker, telemetry: Core::Telemetry::Component, logger: Core::Logger) -> void + + def report: ( + ResolutionDetails result, + flag_key: ::String, + context: ::OpenFeature::SDK::EvaluationContext? + ) -> bool + end + end + end +end + + diff --git a/sig/datadog/open_feature/exposures/worker.rbs b/sig/datadog/open_feature/exposures/worker.rbs new file mode 100644 index 00000000000..47f8af5fffb --- /dev/null +++ b/sig/datadog/open_feature/exposures/worker.rbs @@ -0,0 +1,54 @@ +module Datadog + module OpenFeature + module Exposures + class Worker + include Core::Workers::Queue + include Core::Workers::Polling + # NOTE: Steep is not picking it up on itself with included metaprogramming + include Core::Workers::Async::Thread + include Core::Workers::IntervalLoop + + GRACEFUL_SHUTDOWN_EXTRA_SECONDS: ::Integer + + GRACEFUL_SHUTDOWN_WAIT_INTERVAL_SECONDS: ::Float + + DEFAULT_FLUSH_INTERVAL_SECONDS: ::Integer + + DEFAULT_BUFFER_LIMIT: ::Integer + + attr_reader logger: Core::Logger + + def initialize: ( + settings: Core::Configuration::Settings, + transport: Transport::Exposures::Transport, + telemetry: Core::Telemetry::Component, + logger: Core::Logger, + ?flush_interval_seconds: ::Integer, + ?buffer_limit: ::Integer + ) -> void + + def start: () -> void + + def stop: (?bool, ?::Integer) -> bool + + def enqueue: (Event::t event) -> bool + + def dequeue: () -> [::Array[Event::t], ::Integer] + + def graceful_shutdown: () -> bool + + private + + def loop_base_interval=: (::Integer) -> ::Integer + + def running?: () -> bool + + def forked?: () -> bool + + def perform: (*untyped) -> void + + def send_events: (::Array[Event::t], ::Integer) -> Core::Transport::Response? + end + 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/noop_evaluator.rbs b/sig/datadog/open_feature/noop_evaluator.rbs new file mode 100644 index 00000000000..e7bc0a8a119 --- /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, + untyped, + ::OpenFeature::SDK::EvaluationContext::fields_t, + ::String + ) -> 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..3128b0803c3 --- /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: ::String, + 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/sig/datadog/open_feature/resolution_details.rbs b/sig/datadog/open_feature/resolution_details.rbs new file mode 100644 index 00000000000..c4180ee3d13 --- /dev/null +++ b/sig/datadog/open_feature/resolution_details.rbs @@ -0,0 +1,45 @@ +module Datadog + module OpenFeature + 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 extra_logging: ::Hash[::String, untyped]? + + attr_accessor log?: bool? + + attr_accessor error?: bool? + + def self.new: ( + ?value: untyped, + ?reason: ::String?, + ?variant: ::String?, + ?error_code: ::String?, + ?error_message: ::String?, + ?flag_metadata: ::Hash[::String, untyped]?, + ?allocation_key: ::String?, + ?extra_logging: ::Hash[::String, untyped]?, + ?log?: bool?, + ?error?: bool? + ) -> instance + + def self.build_error: ( + value: untyped, + error_code: ::String, + error_message: ::String, + ?reason: ::String + ) -> instance + end + end +end diff --git a/sig/datadog/open_feature/transport/exposures.rbs b/sig/datadog/open_feature/transport/exposures.rbs new file mode 100644 index 00000000000..07aea934410 --- /dev/null +++ b/sig/datadog/open_feature/transport/exposures.rbs @@ -0,0 +1,33 @@ +module Datadog + module OpenFeature + module Transport + module Exposures + class EncodedParcel + include Datadog::Core::Transport::Parcel + + def encode_with: (Core::Encoding::Encoder) -> ::String + end + + class Request < Datadog::Core::Transport::Request + attr_reader headers: ::Hash[::Symbol, untyped] + + def initialize: (OpenFeature::Transport::Exposures::EncodedParcel, ?::Hash[::Symbol, untyped]?) -> void + end + + class Transport + attr_reader apis: Core::Transport::HTTP::API::Map + + attr_reader client: OpenFeature::Transport::HTTP::Client + + attr_reader default_api: ::String + + attr_reader logger: Core::Logger + + def initialize: (Core::Transport::HTTP::API::Map, ::String, logger: Core::Logger) -> void + + def send_exposures: (::Hash[::Symbol, untyped], ?headers: ::Hash[::Symbol, untyped]?) -> Core::Transport::Response + end + end + end + end +end diff --git a/sig/datadog/open_feature/transport/http.rbs b/sig/datadog/open_feature/transport/http.rbs new file mode 100644 index 00000000000..7d188967465 --- /dev/null +++ b/sig/datadog/open_feature/transport/http.rbs @@ -0,0 +1,13 @@ +module Datadog + module OpenFeature + module Transport + module HTTP + def self?.build: ( + agent_settings: Core::Configuration::AgentSettings, + logger: Core::Logger, + ?headers: ::Hash[::Symbol, untyped]? + ) ?{ (Core::Transport::HTTP::Builder) -> void } -> OpenFeature::Transport::Exposures::Transport + end + end + end +end diff --git a/sig/datadog/open_feature/transport/http/api.rbs b/sig/datadog/open_feature/transport/http/api.rbs new file mode 100644 index 00000000000..0b74e2b7e72 --- /dev/null +++ b/sig/datadog/open_feature/transport/http/api.rbs @@ -0,0 +1,13 @@ +module Datadog + module OpenFeature + module Transport + module HTTP + module API + EXPOSURES: ::String + + def self?.defaults: () -> untyped + end + end + end + end +end diff --git a/sig/datadog/open_feature/transport/http/client.rbs b/sig/datadog/open_feature/transport/http/client.rbs new file mode 100644 index 00000000000..419d7a09a6e --- /dev/null +++ b/sig/datadog/open_feature/transport/http/client.rbs @@ -0,0 +1,22 @@ +module Datadog + module OpenFeature + module Transport + module HTTP + class Client + include Datadog::OpenFeature::Transport::HTTP::Exposures::Client + + attr_reader api: Core::Transport::HTTP::API::Instance + attr_reader logger: Core::Logger + + def initialize: (Core::Transport::HTTP::API::Instance, logger: Core::Logger) -> void + + def send_request: (Transport::Exposures::Request) { (Core::Transport::HTTP::API::Instance, Core::Transport::HTTP::Env) -> Core::Transport::Response } -> Core::Transport::Response + + private + + def build_env: (Transport::Exposures::Request) -> Core::Transport::HTTP::Env + end + end + end + end +end diff --git a/sig/datadog/open_feature/transport/http/exposures.rbs b/sig/datadog/open_feature/transport/http/exposures.rbs new file mode 100644 index 00000000000..5ad7f191dbf --- /dev/null +++ b/sig/datadog/open_feature/transport/http/exposures.rbs @@ -0,0 +1,43 @@ +module Datadog + module OpenFeature + module Transport + module HTTP + module Exposures + module Client + def send_exposures_payload: (Transport::Exposures::Request) -> Core::Transport::Response + + private + + def send_request: (Transport::Exposures::Request) { (OpenFeature::Transport::HTTP::Exposures::API::Instance, Core::Transport::HTTP::Env) -> Core::Transport::Response } -> Core::Transport::Response + end + + module API + class Spec < Datadog::Core::Transport::HTTP::API::Spec + attr_accessor endpoint: Transport::HTTP::Exposures::API::Endpoint? + + def send_exposures: (Core::Transport::HTTP::Env) { (Core::Transport::HTTP::Env) -> Core::Transport::Response } -> Core::Transport::Response + end + + class Instance < Datadog::Core::Transport::HTTP::API::Instance + def send_exposures: (Core::Transport::HTTP::Env) -> Core::Transport::Response + end + + class Endpoint < Datadog::Core::Transport::HTTP::API::Endpoint + HEADER_CONTENT_TYPE: ::String + + HEADER_SUBDOMAIN: ::String + + SUBDOMAIN_VALUE: ::String + + attr_reader encoder: Core::Encoding::Encoder + + def initialize: (::String, Core::Encoding::Encoder) -> void + + def call: (Core::Transport::HTTP::Env) { (Core::Transport::HTTP::Env) -> Core::Transport::Response } -> Core::Transport::Response + end + end + end + end + 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..192b73d992e --- /dev/null +++ b/spec/datadog/open_feature/component_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/component' + +RSpec.describe Datadog::OpenFeature::Component do + before do + allow(Datadog::OpenFeature::Transport::HTTP).to receive(:build).and_return(transport) + allow(Datadog::OpenFeature::Exposures::Worker).to receive(:new).and_return(worker) + allow(Datadog::OpenFeature::Exposures::Reporter).to receive(:new).and_return(reporter) + 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(Datadog::Core::Logger) } + let(:transport) { instance_double(Datadog::OpenFeature::Transport::Exposures::Transport) } + let(:worker) { instance_double(Datadog::OpenFeature::Exposures::Worker) } + let(:reporter) { instance_double(Datadog::OpenFeature::Exposures::Reporter) } + + 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 { settings.open_feature.enabled = true } + + 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) + expect(Datadog::OpenFeature::Exposures::Reporter).to have_received(:new) + 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 as Remote Configuration is currently 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 + end + + describe '#shutdown!' do + before do + settings.open_feature.enabled = true + settings.remote.enabled = true + end + + subject(:component) { described_class.new(settings, agent_settings, logger: logger, telemetry: telemetry) } + + it 'gracefully shutdown the worker' do + expect(worker).to receive(:graceful_shutdown) + + component.shutdown! + 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..6268d2b40a6 --- /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' + +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..130e3eb3ded --- /dev/null +++ b/spec/datadog/open_feature/evaluation_engine_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/evaluation_engine' + +RSpec.describe Datadog::OpenFeature::EvaluationEngine do + let(:engine) { described_class.new(reporter, telemetry: telemetry, logger: logger) } + let(:reporter) { instance_double(Datadog::OpenFeature::Exposures::Reporter) } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:logger) { instance_double(Datadog::Core::Logger) } + let(:configuration) 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) { engine.fetch_value(flag_key: 'test', default_value: 'fallback', expected_type: 'string') } + + context 'when binding evaluator is not ready' do + it 'returns evaluation error and reports exposure' do + expect(reporter).to receive(:report).with(kind_of(Datadog::OpenFeature::ResolutionDetails), flag_key: 'test', context: nil) + + expect(result.value).to eq('fallback') + 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 + engine.reconfigure!(configuration) + allow_any_instance_of(Datadog::OpenFeature::NoopEvaluator).to receive(:get_assignment) + .and_return(error) + end + + let(:error) do + Datadog::OpenFeature::ResolutionDetails.new( + value: 'something', + error_code: 'PROVIDER_FATAL', + error_message: 'Ooops', + reason: 'ERROR', + flag_metadata: {}, + extra_logging: {}, + error?: true, + log?: false + ) + end + + it 'returns evaluation error and reports exposure' do + expect(reporter).to receive(:report).with(error, flag_key: 'test', context: nil) + + expect(result.value).to eq('something') + 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 + engine.reconfigure!(configuration) + allow(telemetry).to receive(:report) + allow_any_instance_of(Datadog::OpenFeature::NoopEvaluator).to receive(:get_assignment) + .and_raise(error) + end + + let(:error) { RuntimeError.new("Crash") } + + it 'returns evaluation error and does not report exposure' do + expect(reporter).not_to receive(:report) + + expect(result.value).to eq('fallback') + 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 { engine.reconfigure!(configuration) } + + let(:result) { engine.fetch_value(flag_key: 'test', default_value: 'x', expected_type: 'whatever') } + + it 'returns evaluation error and does not report exposure' do + expect(reporter).not_to receive(:report) + + expect(result.value).to eq('x') + 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 + + xcontext 'when binding evaluator returns resolution details' do + before { engine.reconfigure!(configuration) } + + let(:evaluation_context) { instance_double('OpenFeature::SDK::EvaluationContext') } + let(:result) do + engine.fetch_value( + flag_key: 'test', default_value: 'bye!', expected_type: 'string', evaluation_context: evaluation_context + ) + end + + it 'returns resolved value and reports exposure' do + expect(reporter).to receive(:report) + .with(kind_of(Datadog::OpenFeature::ResolutionDetails), flag_key: 'test', context: evaluation_context) + + 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/) + + engine.reconfigure!(nil) + end + end + + context 'when binding initialization fails with exception' do + before do + engine.reconfigure!(configuration) + + allow(Datadog::OpenFeature::NoopEvaluator).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/)) + + expect { engine.reconfigure!('{}') }.to raise_error(described_class::ReconfigurationError, 'Ooops') + end + + xit 'persists previouly configured evaluator' do + allow(logger).to receive(:error) + allow(telemetry).to receive(:report) + allow(reporter).to receive(:report) + + expect { engine.reconfigure!('{}') }.to raise_error(described_class::ReconfigurationError, 'Ooops') + expect(engine.fetch_value(flag_key: 'test', default_value: 'bye!', expected_type: 'string').value).to eq('hello') + end + end + + context 'when binding initialization succeeds' do + before { engine.reconfigure!(configuration) } + + let(:new_configuration) 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 { engine.reconfigure!(new_configuration) }.to change { + engine.fetch_value(flag_key: 'test', default_value: 'bye!', expected_type: 'string').value + }.from('hello').to('goodbye') + end + end + end +end diff --git a/spec/datadog/open_feature/exposures/batch_builder_spec.rb b/spec/datadog/open_feature/exposures/batch_builder_spec.rb new file mode 100644 index 00000000000..809b1226970 --- /dev/null +++ b/spec/datadog/open_feature/exposures/batch_builder_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/core/configuration/settings' +require 'datadog/open_feature/exposures/batch_builder' + +RSpec.describe Datadog::OpenFeature::Exposures::BatchBuilder do + subject(:builder) { described_class.new(settings) } + + describe '#payload_for' do + let(:event) do + { + timestamp: 1_735_689_600_000, + allocation: {key: 'control'}, + flag: {key: 'demo'}, + variant: {key: 'a'}, + subject: {id: 'user-1', attributes: {'plan' => 'pro'}} + } + end + + context 'when env, service, and version are present' do + let(:settings) do + Datadog::Core::Configuration::Settings.new.tap do |c| + c.env = 'prod' + c.service = 'dummy-service' + c.version = '1.0.0' + end + end + + it 'returns payload with context fields' do + expect(builder.payload_for([event])).to eq( + context: {env: 'prod', service: 'dummy-service', version: '1.0.0'}, + exposures: [event] + ) + end + end + + context 'when service is nil' do + let(:settings) do + instance_double( + Datadog::Core::Configuration::Settings, + env: 'qa', + service: nil, + version: '2.0.0' + ) + end + + it 'ignores nil context values' do + expect(builder.payload_for([event])).to eq( + context: {env: 'qa', version: '2.0.0'}, + exposures: [event] + ) + end + end + + context 'when settings provide no context information' do + let(:settings) do + instance_double( + Datadog::Core::Configuration::Settings, + env: nil, + service: nil, + version: nil + ) + end + + it 'returns payload with empty context' do + expect(builder.payload_for([event])).to eq({context: {}, exposures: [event]}) + end + end + end +end diff --git a/spec/datadog/open_feature/exposures/buffer_spec.rb b/spec/datadog/open_feature/exposures/buffer_spec.rb new file mode 100644 index 00000000000..53a56ed2a98 --- /dev/null +++ b/spec/datadog/open_feature/exposures/buffer_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/exposures/buffer' + +RSpec.describe Datadog::OpenFeature::Exposures::Buffer do + describe '#push' do + subject(:buffer) { described_class.new(1) } + + it 'drops items if maximum capacity is reached' do + expect { buffer.push(:one) }.to change { buffer.length }.from(0).to(1) + expect { buffer.push(:two) }.not_to change { buffer.length }.from(1) + expect { buffer.push(:three) }.not_to change { buffer.length }.from(1) + end + end + + describe '#pop' do + subject(:buffer) { described_class.new(1) } + + context 'when no items were dropped' do + before { buffer.push(:one) } + + it 'returns the most recent items and sets dropped count' do + expect(buffer.pop).to eq([:one]) + expect(buffer.dropped_count).to be_zero + end + + it 'returns nothing and keeps resetted dropped count' do + expect(buffer.pop).to eq([:one]) + expect(buffer.dropped_count).to be_zero + + expect(buffer.pop).to eq([]) + expect(buffer.dropped_count).to be_zero + end + end + + context 'when some items were dropped' do + before do + buffer.push(:one) + buffer.push(:two) + buffer.push(:three) + end + + it 'returns the most recent items and dropped items counter' do + expect(buffer.pop).to eq([:three]) + expect(buffer.dropped_count).to eq(2) + end + end + end + + describe '#concat' do + let(:buffer) { described_class.new(3) } + + context 'when total size does not exceed capacity' do + it 'appends all items without dropping' do + expect { buffer.concat([:one, :two]) } + .to change { buffer.length }.from(0).to(2) + + expect(buffer.pop).to contain_exactly(:one, :two) + expect(buffer.dropped_count).to be_zero + end + end + + context 'when total size exceeds capacity' do + it 'drops overflow items and records drop count' do + expect { buffer.concat([:one, :two, :three, :four, :five]) } + .to change { buffer.length }.from(0).to(3) + + expect(buffer.pop.length).to eq(3) + expect(buffer.dropped_count).to eq(2) + end + end + end +end diff --git a/spec/datadog/open_feature/exposures/deduplicator_spec.rb b/spec/datadog/open_feature/exposures/deduplicator_spec.rb new file mode 100644 index 00000000000..34077669dec --- /dev/null +++ b/spec/datadog/open_feature/exposures/deduplicator_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/exposures/deduplicator' + +RSpec.describe Datadog::OpenFeature::Exposures::Deduplicator do + subject(:deduplicator) { described_class.new(limit: 2) } + + describe '#duplicate?' do + context 'when exposure was never seen' do + it { expect(deduplicator.duplicate?('flag:user', 'alloc:variant')).to be(false) } + end + + context 'when exposure was already reported' do + before { deduplicator.duplicate?('flag:user', 'alloc:variant') } + + it { expect(deduplicator.duplicate?('flag:user', 'alloc:variant')).to be(true) } + end + + context 'when variation key changes' do + before { deduplicator.duplicate?('flag:user', 'alloc:variant') } + + it { expect(deduplicator.duplicate?('flag:user', 'alloc:other')).to be(false) } + end + + context 'when allocation key changes' do + before { deduplicator.duplicate?('flag:user', 'alloc:variant') } + + it { expect(deduplicator.duplicate?('flag:user', 'other:variant')).to be(false) } + end + + context 'when targeting key changes' do + before { deduplicator.duplicate?('flag:user', 'alloc:variant') } + + it { expect(deduplicator.duplicate?('flag:other', 'alloc:variant')).to be(false) } + end + + context 'when cache evicts previous exposure' do + before do + deduplicator.duplicate?('flag-one:user', 'alloc:variant') + deduplicator.duplicate?('flag-two:user', 'alloc:variant') + deduplicator.duplicate?('flag-three:user', 'alloc:variant') + end + + it 'returns false after LRU eviction and true when cached' do + expect(deduplicator.duplicate?('flag-one:user', 'alloc:variant')).to be(false) + expect(deduplicator.duplicate?('flag-one:user', 'alloc:variant')).to be(true) + end + end + end +end diff --git a/spec/datadog/open_feature/exposures/event_spec.rb b/spec/datadog/open_feature/exposures/event_spec.rb new file mode 100644 index 00000000000..f9762419230 --- /dev/null +++ b/spec/datadog/open_feature/exposures/event_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/exposures/event' + +RSpec.describe Datadog::OpenFeature::Exposures::Event do + before { allow(Datadog::Core::Utils::Time).to receive(:now).and_return(now) } + + let(:now) { Time.utc(2025, 1, 1, 0, 0, 0) } + let(:event) { described_class.build(result, flag_key: 'feature_flag', context: context) } + let(:result) do + Datadog::OpenFeature::ResolutionDetails.new( + value: 4, + allocation_key: '4-for-john-doe', + variant: '4', + flag_metadata: { + 'allocationKey' => '4-for-john-doe', + 'variationType' => 'number', + 'doLog' => true + }, + log?: true, + error?: false + ) + end + + describe '.build' do + context 'when context contains nested fields' do + let(:context) do + instance_double( + 'OpenFeature::SDK::EvaluationContext', + targeting_key: 'john-doe', + fields: { + 'targeting_key' => 'john-doe', + 'age' => 21, + 'active' => true, + 'ratio' => 7.5, + 'nickname' => 'johnny', + 'ignored_hash' => {foo: 'bar'}, + 'ignored_array' => [1, 2] + } + ) + end + let(:expected) do + { + timestamp: 1_735_689_600_000, + allocation: {key: '4-for-john-doe'}, + flag: {key: 'feature_flag'}, + variant: {key: '4'}, + subject: { + id: 'john-doe', + attributes: {'age' => 21, 'active' => true, 'ratio' => 7.5, 'nickname' => 'johnny'} + } + } + end + + it 'builds exposure event and dropps nested fields' do + expect(event).to eq(expected) + end + end + + context 'when context does not contain extra fields' do + let(:context) do + instance_double( + 'OpenFeature::SDK::EvaluationContext', targeting_key: 'john-doe', fields: {'targeting_key' => 'john-doe'} + ) + end + let(:expected) do + { + timestamp: 1_735_689_600_000, + allocation: {key: '4-for-john-doe'}, + flag: {key: 'feature_flag'}, + variant: {key: '4'}, + subject: {id: 'john-doe', attributes: {}} + } + end + + it { expect(event).to eq(expected) } + end + end + + describe '.cache_key' do + let(:context) { instance_double('OpenFeature::SDK::EvaluationContext', targeting_key: 'john-doe') } + + it 'returns cache key based on flag and targeting key' do + expect(described_class.cache_key(result, flag_key: 'feature_flag', context: context)) + .to eq('feature_flag:john-doe') + end + end + + describe '.cache_value' do + let(:context) { instance_double('OpenFeature::SDK::EvaluationContext', targeting_key: 'john-doe') } + + it 'returns cache value based on allocation and variant' do + expect(described_class.cache_value(result, flag_key: 'feature_flag', context: context)) + .to eq('4-for-john-doe:4') + end + end +end diff --git a/spec/datadog/open_feature/exposures/reporter_spec.rb b/spec/datadog/open_feature/exposures/reporter_spec.rb new file mode 100644 index 00000000000..1a2c3f4dc6c --- /dev/null +++ b/spec/datadog/open_feature/exposures/reporter_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/exposures/reporter' + +RSpec.describe Datadog::OpenFeature::Exposures::Reporter do + before do + allow(Datadog::OpenFeature::Exposures::Deduplicator).to receive(:new).and_return(deduplicator) + end + + subject(:reporter) { described_class.new(worker, telemetry: telemetry, logger: logger) } + + let(:worker) { instance_double(Datadog::OpenFeature::Exposures::Worker) } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:logger) { logger_allowing_debug } + + let(:deduplicator) { instance_double(Datadog::OpenFeature::Exposures::Deduplicator) } + let(:context) do + instance_double( + 'OpenFeature::SDK::EvaluationContext', targeting_key: 'john-doe', fields: {'targeting_key' => 'john-doe'} + ) + end + let(:result) do + Datadog::OpenFeature::ResolutionDetails.new( + value: 4, + allocation_key: '4-for-john-doe', + variant: '4', + flag_metadata: { + 'allocationKey' => '4-for-john-doe', + 'variationType' => 'number', + 'doLog' => true + }, + log?: true, + error?: false + ) + end + + describe '#report' do + context 'when exposure has not been reported' do + before { allow(deduplicator).to receive(:duplicate?).and_return(false) } + + it 'enqueues event' do + expect(worker).to receive(:enqueue).and_return(true) + expect(reporter.report(result, flag_key: 'feature_flag', context: context)).to be(true) + end + end + + context 'when exposure was already reported' do + before { allow(deduplicator).to receive(:duplicate?).and_return(true) } + + it 'does not enqueue event again' do + expect(worker).not_to receive(:enqueue) + expect(reporter.report(result, flag_key: 'feature_flag', context: context)).to be(false) + end + end + + context 'when worker enqueue fails' do + before do + allow(deduplicator).to receive(:duplicate?).and_return(false) + allow(worker).to receive(:enqueue).and_raise(error) + end + + let(:error) { StandardError.new('Oops') } + + it 'returns false and logs debug message' do + expect(telemetry).to receive(:report).with(error, description: /Failed to report resolution details/) + expect_lazy_log(logger, :debug, /Failed to report resolution details: StandardError: Oops/) + expect(reporter.report(result, flag_key: 'feature_flag', context: context)).to be(false) + end + end + + context 'when event should not be reported' do + let(:result) do + Datadog::OpenFeature::ResolutionDetails.new( + value: 4, + allocation_key: '4-for-john-doe', + flag_metadata: {}, + log?: false, + error?: true + ) + end + + it 'skips enqueueing exposure' do + expect(deduplicator).not_to receive(:duplicate?) + expect(worker).not_to receive(:enqueue) + + expect(reporter.report(result, flag_key: 'feature_flag', context: context)).to be(false) + end + end + + context 'when evaluation context is nil' do + it 'skips enqueueing exposure' do + expect(deduplicator).not_to receive(:duplicate?) + expect(worker).not_to receive(:enqueue) + + expect(reporter.report(result, flag_key: 'feature_flag', context: nil)).to be(false) + end + end + end +end diff --git a/spec/datadog/open_feature/exposures/worker_spec.rb b/spec/datadog/open_feature/exposures/worker_spec.rb new file mode 100644 index 00000000000..270db187438 --- /dev/null +++ b/spec/datadog/open_feature/exposures/worker_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/transport/exposures' + +RSpec.describe Datadog::OpenFeature::Exposures::Worker do + after do + worker.stop(true, 0.1) + worker.join + end + + subject(:worker) do + described_class.new( + settings: settings, + transport: transport, + telemetry: telemetry, + logger: logger, + flush_interval_seconds: 0.1, + buffer_limit: 2 + ) + end + + let(:settings) { Datadog::Core::Configuration::Settings.new } + let(:transport) { instance_double(Datadog::OpenFeature::Transport::Exposures::Transport) } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:response) { instance_double(Datadog::Core::Transport::HTTP::Adapters::Net::Response, ok?: true) } + let(:logger) { logger_allowing_debug } + let(:event) do + { + timestamp: 1_735_689_600_000, + allocation: {key: 'control'}, + flag: {key: 'demo-flag'}, + variant: {key: 'v1'}, + subject: {id: 'user-1', attributes: {'plan' => 'pro'}} + } + end + + describe '#start' do + context 'when worker is disabled' do + it 'does nothing' do + allow(worker).to receive(:enabled?).and_return(false) + + worker.start + + expect(worker).not_to be_running + expect(worker).not_to be_started + end + end + end + + describe '#enqueue' do + context 'when worker is not started' do + let(:event_2) do + { + timestamp: 1_735_689_600_000, + allocation: {key: 'control-2'}, + flag: {key: 'demo-flag2'}, + variant: {key: 'v2'}, + subject: {id: 'user-2', attributes: {'plan' => 'pro'}} + } + end + let(:event_3) do + { + timestamp: 1_735_689_600_000, + allocation: {key: 'control-3'}, + flag: {key: 'demo-flag3'}, + variant: {key: 'v3'}, + subject: {id: 'user-3', attributes: {'plan' => 'pro'}} + } + end + + it 'starts on demand and processes buffer' do + batches_sent = 0 + allow(transport).to receive(:send_exposures) do |payload| + batches_sent += 1 + response + end + + worker.enqueue(event) + worker.enqueue(event_2) + worker.enqueue(event_3) + + try_wait_until { worker.running? } + try_wait_until { batches_sent.positive? } + + expect(batches_sent).to eq(1) + end + end + + context 'when transport response does not have expected interface' do + before { allow(transport).to receive(:send_exposures).and_return(response) } + + let(:response) { nil } + + it 'logs debug message' do + worker.enqueue(event) + try_wait_until { worker.running? } + + expect_lazy_log(logger, :debug, /Resolution details upload response was not OK/) + end + end + + context 'when transport response is not ok' do + before { allow(transport).to receive(:send_exposures).and_return(response) } + + let(:response) { instance_double(Datadog::Core::Transport::HTTP::Adapters::Net::Response, ok?: false) } + + it 'logs debug message' do + worker.enqueue(event) + try_wait_until { worker.running? } + + expect_lazy_log(logger, :debug, /Resolution details upload response was not OK/) + end + end + + context 'when transport raises an error' do + before { allow(transport).to receive(:send_exposures).and_raise(error) } + + let(:error) { StandardError.new('Ooops') } + + it 'logs debug message and swallows the error' do + expect(telemetry).to receive(:report).with(error, description: /Failed to flush resolution details events/) + + worker.enqueue(event) + try_wait_until { worker.running? } + + expect_lazy_log(logger, :debug, /Failed to flush resolution details events/) + expect { worker.perform }.not_to raise_error + end + end + end + + describe '#graceful_shutdown' do + context 'when buffer contains events' do + before do + stub_const('Datadog::OpenFeature::Exposures::Worker::GRACEFUL_SHUTDOWN_EXTRA_SECONDS', 0.1) + stub_const('Datadog::OpenFeature::Exposures::Worker::GRACEFUL_SHUTDOWN_WAIT_INTERVAL_SECONDS', 0.1) + end + + let(:event_2) do + { + timestamp: 1_735_689_600_000, + allocation: {key: 'control-2'}, + flag: {key: 'demo-flag2'}, + variant: {key: 'v2'}, + subject: {id: 'user-2', attributes: {'plan' => 'pro'}} + } + end + + it 'flushes remaining events before stopping' do + batches_sent = 0 + allow(transport).to receive(:send_exposures) do |payload| + batches_sent += 1 + response + end + + worker.enqueue(event) + try_wait_until { worker.running? } + try_wait_until { batches_sent.positive? } + + worker.enqueue(event_2) + worker.graceful_shutdown + try_wait_until { !worker.running? } + + expect(batches_sent).to eq(2) + end + 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..1d25c74a4d8 --- /dev/null +++ b/spec/datadog/open_feature/noop_evaluator_spec.rb @@ -0,0 +1,21 @@ +# 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', 'fallback', {}, 'string') } + + it 'returns provider not ready result' do + expect(result).to be_error + expect(result).not_to be_log + 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') + expect(result.value).to eq('fallback') + 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..8946469adc4 --- /dev/null +++ b/spec/datadog/open_feature/provider_spec.rb @@ -0,0 +1,366 @@ +# 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(reporter).to receive(:report) + allow(Datadog::OpenFeature).to receive(:engine).and_return(engine) + end + + let(:engine) { Datadog::OpenFeature::EvaluationEngine.new(reporter, telemetry: telemetry, logger: logger) } + let(:reporter) { instance_double(Datadog::OpenFeature::Exposures::Reporter) } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:logger) { instance_double(Datadog::Core::Logger) } + + 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 + + xcontext 'when engine is configured' do + before do + engine.configuration = configuration + engine.reconfigure! + end + + let(:result) { provider.fetch_boolean_value(flag_key: 'flag', default_value: false) } + let(:configuration) 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 + + xcontext 'when engine is configured' do + before do + engine.configuration = configuration + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_string_value(flag_key: 'flag', default_value: 'default') } + let(:configuration) 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 + + xcontext 'when engine is configured' do + before do + engine.configuration = configuration + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_number_value(flag_key: 'flag', default_value: 0) } + let(:configuration) 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 + + xcontext 'when engine is configured' do + before do + engine.configuration = configuration + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_integer_value(flag_key: 'flag', default_value: 1) } + let(:configuration) 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 + + xcontext 'when engine is configured' do + before do + engine.configuration = configuration + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_float_value(flag_key: 'flag', default_value: 0.0) } + let(:configuration) 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 + + xcontext 'when engine is configured' do + before do + engine.configuration = configuration + engine.reconfigure! + + provider.init + end + + let(:result) { provider.fetch_object_value(flag_key: 'flag', default_value: {'default' => true}) } + let(:configuration) 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..6cbcc1acf6b --- /dev/null +++ b/spec/datadog/open_feature/remote_spec.rb @@ -0,0 +1,207 @@ +# 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(:telemetry) { instance_double(Datadog::Core::Telemetry::Component) } + let(:receivers) { described_class.receivers(telemetry) } + let(:receiver) { receivers[0] } + let(:logger) { instance_double(Datadog::Core::Logger) } + + describe '.capabilities' do + it { expect(described_class.capabilities).to eq([70368744177664]) } + end + + describe '.products' do + it { expect(described_class.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(reporter, telemetry: telemetry, logger: logger) } + let(:reporter) { instance_double(Datadog::OpenFeature::Exposures::Reporter) } + 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 + { + "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!).with(content_data) + + receiver.call(repository, transaction) + + expect(content.apply_state).to eq(Datadog::Core::Remote::Configuration::Content::ApplyState::ACKNOWLEDGED) + end + end + + context 'when change type is insert and reconfigure fails' do + before { allow(engine).to receive(:reconfigure!).and_raise(error) } + + let(:error) { Datadog::OpenFeature::EvaluationEngine::ReconfigurationError.new('Ooops') } + let(:transaction) do + repository.transaction { |_, t| t.insert(content.path, target, content) } + end + + it 'marks content as errored' do + receiver.call(repository, transaction) + + expect(content.apply_state).to eq(Datadog::Core::Remote::Configuration::Content::ApplyState::ERROR) + 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!).with(new_content_data) + + receiver.call(repository, transaction) + + 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 data cannot be read' do + before { allow(content.data).to receive(:read).and_return(nil) } + + let(:transaction) do + repository.transaction { |_, t| t.insert(content.path, target, content) } + end + + it 'marks content as errored' do + receiver.call(repository, transaction) + + expect(content.apply_state).to eq(Datadog::Core::Remote::Configuration::Content::ApplyState::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 is missing and does not reconfigure the engine' do + expect(telemetry).to receive(:error).with(/Remote Configuration change is not present/) + expect(engine).not_to receive(:reconfigure!) + + receiver.call(repository, changes) + end + end + end +end diff --git a/spec/datadog/open_feature/resolution_details_spec.rb b/spec/datadog/open_feature/resolution_details_spec.rb new file mode 100644 index 00000000000..8508e6d5b1a --- /dev/null +++ b/spec/datadog/open_feature/resolution_details_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'datadog/open_feature/resolution_details' + +RSpec.describe Datadog::OpenFeature::ResolutionDetails do + describe '.build_error' do + context 'when reason is not provided' do + subject(:details) { described_class.build_error(value: 'fallback', error_code: 'CODE', error_message: 'Oops') } + + it 'returns frozen error details with default reason' do + expect(details).to be_frozen + expect(details.value).to eq('fallback') + expect(details.error_code).to eq('CODE') + expect(details.error_message).to eq('Oops') + expect(details.reason).to eq('ERROR') + expect(details.error?).to be(true) + expect(details.log?).to be(false) + end + end + + context 'when reason is provided' do + subject(:details) do + described_class.build_error(value: 'fallback', error_code: 'CODE', error_message: 'Oops', reason: 'CUSTOM') + end + + it 'returns error details with given reason' do + expect(details.reason).to eq('CUSTOM') + 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..c78b0505e6c --- /dev/null +++ b/spec/datadog/open_feature_spec.rb @@ -0,0 +1,73 @@ +# 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 and remote configuration are available' do + around do |example| + Datadog.configure do |c| + c.remote.enabled = true + c.open_feature.enabled = true + end + + example.run + ensure + Datadog.configuration.reset! + end + + it { expect(described_class.engine).to be_a(Datadog::OpenFeature::EvaluationEngine) } + end + + context 'when component is available and remote configuration is not available' do + around do |example| + Datadog.configure do |c| + c.remote.enabled = false + c.open_feature.enabled = true + end + + example.run + ensure + Datadog.configuration.reset! + end + + it { expect(described_class.engine).to be_nil } + end + end +end diff --git a/spec/loading_spec.rb b/spec/loading_spec.rb index 1bcba89507b..78e9e0afcdf 100644 --- a/spec/loading_spec.rb +++ b/spec/loading_spec.rb @@ -21,6 +21,7 @@ {require: 'datadog/kit', check: 'Datadog::Kit'}, {require: 'datadog/profiling', check: 'Datadog::Profiling'}, {require: 'datadog/tracing', check: 'Datadog::Tracing'}, + {require: 'datadog/open_feature', check: 'Datadog::OpenFeature'}, ].freeze RSpec.describe 'loading of products' do diff --git a/supported-configurations.json b/supported-configurations.json index a52d162b18b..346cb7946f4 100644 --- a/supported-configurations.json +++ b/supported-configurations.json @@ -109,6 +109,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 index 1d961d49a47..84dd3e8595b 100644 --- a/vendor/rbs/openfeature-sdk/0/openfeature-sdk.rbs +++ b/vendor/rbs/openfeature-sdk/0/openfeature-sdk.rbs @@ -1,9 +1,11 @@ module OpenFeature module SDK class EvaluationContext + type fields_t = ::Hash[::String, untyped] + def targeting_key: () -> ::String? - def fields: () -> ::Hash[::String, untyped] + def fields: () -> fields_t end module Provider