diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..de1db2b4 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "Ruby SDK", + + "image": "mcr.microsoft.com/devcontainers/ruby:1-3.3-bullseye", + + "postCreateCommand": "set -e && bundle install && gem install optimizely-sdk && rake build && gem install pkg/* && gem install solargraph", + + "customizations": { + "vscode": { + "extensions": [ + "eamodio.gitlens", + "github.vscode-github-actions", + "castwide.solargraph" + ] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 00000000..d4b638dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,94 @@ +name: 🐞 Bug +description: File a bug/issue +title: "[BUG] " +labels: ["bug", "needs-triage"] +body: +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: SDK Version + description: Version of the SDK in use? + validations: + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: true +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 1. With this config... + 1. Run '...' + 1. See error... + validations: + required: true +- type: textarea + attributes: + label: Ruby Version + description: What version of Ruby are you using? + validations: + required: false +- type: textarea + attributes: + label: Rails + description: If you're using Rail, what version? + validations: + required: false +- type: textarea + attributes: + label: Link + description: Link to code demonstrating the problem. + validations: + required: false +- type: textarea + attributes: + label: Logs + description: Logs/stack traces related to the problem (⚠️do not include sensitive information). + validations: + required: false +- type: dropdown + attributes: + label: Severity + description: What is the severity of the problem? + multiple: true + options: + - Blocking development + - Affecting users + - Minor issue + validations: + required: false +- type: textarea + attributes: + label: Workaround/Solution + description: Do you have any workaround or solution in mind for the problem? + validations: + required: false +- type: textarea + attributes: + label: Recent Change + description: Has this issue started happening after an update or experiment change? + validations: + required: false +- type: textarea + attributes: + label: Conflicts + description: Are there other libraries/dependencies potentially in conflict? + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml new file mode 100644 index 00000000..42d8a302 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml @@ -0,0 +1,45 @@ +name: ✨Enhancement +description: Create a new ticket for a Enhancement/Tech-initiative for the benefit of the SDK which would be considered for a minor version update. +title: "[ENHANCEMENT] <title>" +labels: ["enhancement"] +body: + - type: textarea + id: description + attributes: + label: Description + description: Briefly describe the enhancement in a few sentences. + placeholder: Short description... + validations: + required: true + - type: textarea + id: benefits + attributes: + label: Benefits + description: How would the enhancement benefit to your product or usage? + placeholder: Benefits... + validations: + required: true + - type: textarea + id: detail + attributes: + label: Detail + description: How would you like the enhancement to work? Please provide as much detail as possible + placeholder: Detailed description... + validations: + required: false + - type: textarea + id: examples + attributes: + label: Examples + description: Are there any examples of this enhancement in other products/services? If so, please provide links or references. + placeholder: Links/References... + validations: + required: false + - type: textarea + id: risks + attributes: + label: Risks/Downsides + description: Do you think this enhancement could have any potential downsides or risks? + placeholder: Risks/Downsides... + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md new file mode 100644 index 00000000..a061f335 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.md @@ -0,0 +1,4 @@ +<!-- + Thanks for filing in issue! Are you requesting a new feature? If so, please share your feedback with us on the following link. +--> +## Feedback requesting a new feature can be shared [here.](https://feedback.optimizely.com/) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d28ef3dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 💡Feature Requests + url: https://feedback.optimizely.com/ + about: Feedback requesting a new feature can be shared here. \ No newline at end of file diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 03a424f2..0b8d086e 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -23,15 +23,19 @@ jobs: path: 'home/runner/travisci-tools' ref: 'master' - name: set SDK Branch if PR + env: + HEAD_REF: ${{ github.head_ref }} if: ${{ github.event_name == 'pull_request' }} run: | - echo "SDK_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV - echo "TRAVIS_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV + echo "SDK_BRANCH=$HEAD_REF" >> $GITHUB_ENV + echo "TRAVIS_BRANCH=$HEAD_REF" >> $GITHUB_ENV - name: set SDK Branch if not pull request + env: + REF_NAME: ${{ github.ref_name }} if: ${{ github.event_name != 'pull_request' }} run: | - echo "SDK_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV - echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "SDK_BRANCH=$REF_NAME" >> $GITHUB_ENV + echo "TRAVIS_BRANCH=$REF_NAME" >> $GITHUB_ENV - name: Trigger build env: SDK: ruby diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 1e22e74e..98f73108 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby: [ '2.7.0', '3.0.0', '3.1.0' ] + ruby: [ '3.0.0', '3.1.0', '3.2.0', '3.3.0' ] steps: - uses: actions/checkout@v3 - name: Set up Ruby ${{ matrix.ruby }} diff --git a/.rubocop.yml b/.rubocop.yml index ea105dd6..1bbc4a4a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,7 +1,7 @@ inherit_from: .rubocop_todo.yml AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.0 Layout/SpaceInsideHashLiteralBraces: EnforcedStyle: no_space diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c54b808..0330fa06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Optimizely Ruby SDK Changelog +## 5.1.0 +January 10th, 2025 + +Added support for batch processing in DecideAll and DecideForKeys, enabling more efficient handling of multiple decisions in the User Profile Service.([#353](https://github.com/optimizely/ruby-sdk/pull/353)) + +## 5.0.1 +February 8th, 2024 + +The 5.0.1 minor release introduces update of metadata in gemspec. + +## 5.0.0 +January 18th, 2024 + +### New Features + +The 5.0.0 release introduces a new primary feature, [Advanced Audience Targeting]( https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) enabled through integration with [Optimizely Data Platform (ODP)](https://docs.developers.optimizely.com/optimizely-data-platform/docs) +([#303](https://github.com/optimizely/ruby-sdk/pull/303), +[#308](https://github.com/optimizely/ruby-sdk/pull/308), +[#310](https://github.com/optimizely/ruby-sdk/pull/310), +[#311](https://github.com/optimizely/ruby-sdk/pull/311), +[#312](https://github.com/optimizely/ruby-sdk/pull/312), +[#314](https://github.com/optimizely/ruby-sdk/pull/314), +[#316](https://github.com/optimizely/ruby-sdk/pull/316)). +You can use ODP, a high-performance [Customer Data Platform (CDP)]( https://www.optimizely.com/optimization-glossary/customer-data-platform/), to easily create complex real-time segments (RTS) using first-party and 50+ third-party data sources out of the box. You can create custom schemas that support the user attributes important for your business, and stitch together user behavior done on different devices to better understand and target your customers for personalized user experiences. ODP can be used as a single source of truth for these segments in any Optimizely or 3rd party tool. + +With ODP accounts integrated into Optimizely projects, you can build audiences using segments pre-defined in ODP. The SDK will fetch the segments for given users and make decisions using the segments. For access to ODP audience targeting in your Feature Experimentation account, please contact your Optimizely Customer Success Manager. + +This version includes the following changes: + +* New API added to `OptimizelyUserContext`: + + * `fetch_qualified_segments()`: this API will retrieve user segments from the ODP server. The fetched segments will be used for audience evaluation. The fetched data will be stored in the local cache to avoid repeated network delays. + + * When an `OptimizelyUserContext` is created, the SDK will automatically send an identify request to the ODP server to facilitate observing user activities. + +* New APIs added to `Optimizely::Project`: + + * `send_odp_event()`: customers can build/send arbitrary ODP events that will bind user identifiers and data to user profiles in ODP. + +For details, refer to our documentation pages: + +* [Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizely-data-platform-advanced-audience-targeting) + +* [Server SDK Support](https://docs.developers.optimizely.com/feature-experimentation/v1.0/docs/advanced-audience-targeting-for-server-side-sdks) + +* [Initialize Ruby SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/initialize-sdk-ruby) + +* [OptimizelyUserContext Ruby SDK](https://docs.developers.optimizely.com/feature-experimentation/docs/optimizelyusercontext-ruby) + +* [Advanced Audience Targeting segment qualification methods](https://docs.developers.optimizely.com/feature-experimentation/docs/advanced-audience-targeting-segment-qualification-methods-ruby) + +* [Send Optimizely Data Platform data using Advanced Audience Targeting](https://docs.developers.optimizely.com/feature-experimentation/docs/send-odp-data-using-advanced-audience-targeting-ruby) + +### Logging + +* Add warning to polling intervals below 30 seconds ([#338](https://github.com/optimizely/ruby-sdk/pull/338)) +* Add warning to duplicate experiment keys ([#343](https://github.com/optimizely/ruby-sdk/pull/343)) + +### Enhancements +* Removed polling config manager stop restriction, allowing it to be restarted ([#340](https://github.com/optimizely/ruby-sdk/pull/340)). +* Include object id/key in invalid object errors ([#301](https://github.com/optimizely/ruby-sdk/pull/301)). + +### Breaking Changes + +* Updated required Ruby version from 2.7 -> 3.0 +* `Optimizely::Project` initialization arguments have been changed from positional to keyword ([#342](https://github.com/optimizely/ruby-sdk/pull/342)). +* `ODPManager` in the SDK is enabled by default. Unless an ODP account is integrated into the Optimizely projects, most `ODPManager` functions will be ignored. If needed, `ODPManager` can be disabled when `Optimizely::Project` is instantiated. + +* `ProjectConfigManager` interface now requires a `sdk_key` method ([#323](https://github.com/optimizely/ruby-sdk/pull/323)). +* `HTTPProjectConfigManager` requires either the `sdk_key` parameter or a datafile containing an sdkKey ([#323](https://github.com/optimizely/ruby-sdk/pull/323)). +* `BatchEventProcessor` is now the default `EventProcessor` when `Optimizely::Project` is instantiated ([#325](https://github.com/optimizely/ruby-sdk/pull/325)). + ## 5.0.0-beta April 28th, 2023 diff --git a/LICENSE b/LICENSE index 006d13d5..e2d14477 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2016, Optimizely and contributors + © Optimizely 2016 Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index a10ba8d9..be5e0613 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Refer to the [Ruby SDK's developer documentation](https://docs.developers.optimi ### Requirements -* Ruby 2.7+ +* Ruby 3.0+ ### Install the SDK @@ -41,7 +41,7 @@ You can initialize the Optimizely instance in two ways: directly with a datafile Initialize Optimizely with a datafile. This datafile will be used as ProjectConfig throughout the life of the Optimizely instance. ```ruby - optimizely_instance = Optimizely::Project.new(datafile) + optimizely_instance = Optimizely::Project.new(datafile: datafile) ``` #### Initialization by OptimizelyFactory @@ -78,6 +78,8 @@ You can initialize the Optimizely instance in two ways: directly with a datafile ) ``` +**Note:** The SDK spawns multiple threads when initialized. These threads have infinite loops that are used for fetching the datafile, as well as batching and dispatching events in the background. When using in a web server that spawn multiple child processes, you need to initialize the SDK after those child processes or workers have been spawned. + #### HTTP Config Manager The `HTTPConfigManager` asynchronously polls for datafiles from a specified URL at regular intervals by making HTTP requests. diff --git a/lib/optimizely.rb b/lib/optimizely.rb index 93f4fc3c..f2e1dd82 100644 --- a/lib/optimizely.rb +++ b/lib/optimizely.rb @@ -42,6 +42,7 @@ require_relative 'optimizely/odp/lru_cache' require_relative 'optimizely/odp/odp_manager' require_relative 'optimizely/helpers/sdk_settings' +require_relative 'optimizely/user_profile_tracker' module Optimizely class Project @@ -70,20 +71,20 @@ class Project # @param event_processor_options: Optional hash of options to be passed to the default batch event processor. # @param settings: Optional instance of OptimizelySdkSettings for sdk configuration. - def initialize( # rubocop:disable Metrics/ParameterLists - datafile = nil, - event_dispatcher = nil, - logger = nil, - error_handler = nil, - skip_json_validation = false, # rubocop:disable Style/OptionalBooleanParameter - user_profile_service = nil, - sdk_key = nil, - config_manager = nil, - notification_center = nil, - event_processor = nil, - default_decide_options = [], - event_processor_options = {}, - settings = nil + def initialize( + datafile: nil, + event_dispatcher: nil, + logger: nil, + error_handler: nil, + skip_json_validation: false, + user_profile_service: nil, + sdk_key: nil, + config_manager: nil, + notification_center: nil, + event_processor: nil, + default_decide_options: [], + event_processor_options: {}, + settings: nil ) @logger = logger || NoOpLogger.new @error_handler = error_handler || NoOpErrorHandler.new @@ -172,71 +173,29 @@ def create_user_context(user_id, attributes = nil) OptimizelyUserContext.new(self, user_id, attributes) end - def decide(user_context, key, decide_options = []) - # raising on user context as it is internal and not provided directly by the user. - raise if user_context.class != OptimizelyUserContext - - reasons = [] - - # check if SDK is ready - unless is_valid - @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message) - reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY) - return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) - end - - # validate that key is a string - unless key.is_a?(String) - @logger.log(Logger::ERROR, 'Provided key is invalid') - reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key)) - return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) - end - - # validate that key maps to a feature flag - config = project_config - feature_flag = config.get_feature_flag_from_key(key) - unless feature_flag - @logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.") - reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key)) - return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) - end - - # merge decide_options and default_decide_options - if decide_options.is_a? Array - decide_options += @default_decide_options - else - @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.') - decide_options = @default_decide_options - end - + def create_optimizely_decision(user_context, flag_key, decision, reasons, decide_options, config) # Create Optimizely Decision Result. user_id = user_context.user_id attributes = user_context.user_attributes variation_key = nil feature_enabled = false rule_key = nil - flag_key = key all_variables = {} decision_event_dispatched = false + feature_flag = config.get_feature_flag_from_key(flag_key) experiment = nil decision_source = Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] - context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil) - variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context) - reasons.push(*reasons_received) - - if variation - decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST']) - else - decision, reasons_received = @decision_service.get_variation_for_feature(config, feature_flag, user_context, decide_options) - reasons.push(*reasons_received) - end + experiment_id = nil + variation_id = nil # Send impression event if Decision came from a feature test and decide options doesn't include disableDecisionEvent if decision.is_a?(Optimizely::DecisionService::Decision) experiment = decision.experiment rule_key = experiment ? experiment['key'] : nil + experiment_id = experiment ? experiment['id'] : nil variation = decision['variation'] variation_key = variation ? variation['key'] : nil + variation_id = variation ? variation['id'] : nil feature_enabled = variation ? variation['featureEnabled'] : false decision_source = decision.source end @@ -249,7 +208,7 @@ def decide(user_context, key, decide_options = []) # Generate all variables map if decide options doesn't include excludeVariables unless decide_options.include? OptimizelyDecideOption::EXCLUDE_VARIABLES feature_flag['variables'].each do |variable| - variable_value = get_feature_variable_for_variation(key, feature_enabled, variation, variable, user_id) + variable_value = get_feature_variable_for_variation(flag_key, feature_enabled, variation, variable, user_id) all_variables[variable['key']] = Helpers::VariableType.cast_value_to_type(variable_value, variable['type'], @logger) end end @@ -260,14 +219,16 @@ def decide(user_context, key, decide_options = []) @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], Helpers::Constants::DECISION_NOTIFICATION_TYPES['FLAG'], - user_id, (attributes || {}), + user_id, attributes || {}, flag_key: flag_key, enabled: feature_enabled, variables: all_variables, variation_key: variation_key, rule_key: rule_key, reasons: should_include_reasons ? reasons : [], - decision_event_dispatched: decision_event_dispatched + decision_event_dispatched: decision_event_dispatched, + experiment_id: experiment_id, + variation_id: variation_id ) OptimizelyDecision.new( @@ -281,6 +242,47 @@ def decide(user_context, key, decide_options = []) ) end + def decide(user_context, key, decide_options = []) + # raising on user context as it is internal and not provided directly by the user. + raise if user_context.class != OptimizelyUserContext + + reasons = [] + + # check if SDK is ready + unless is_valid + @logger.log(Logger::ERROR, InvalidProjectConfigError.new('decide').message) + reasons.push(OptimizelyDecisionMessage::SDK_NOT_READY) + return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) + end + + # validate that key is a string + unless key.is_a?(String) + @logger.log(Logger::ERROR, 'Provided key is invalid') + reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key)) + return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) + end + + # validate that key maps to a feature flag + config = project_config + feature_flag = config.get_feature_flag_from_key(key) + unless feature_flag + @logger.log(Logger::ERROR, "No feature flag was found for key '#{key}'.") + reasons.push(format(OptimizelyDecisionMessage::FLAG_KEY_INVALID, key)) + return OptimizelyDecision.new(flag_key: key, user_context: user_context, reasons: reasons) + end + + # merge decide_options and default_decide_options + if decide_options.is_a? Array + decide_options += @default_decide_options + else + @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.') + decide_options = @default_decide_options + end + + decide_options.delete(OptimizelyDecideOption::ENABLED_FLAGS_ONLY) if decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY) + decide_for_keys(user_context, [key], decide_options, true)[key] + end + def decide_all(user_context, decide_options = []) # raising on user context as it is internal and not provided directly by the user. raise if user_context.class != OptimizelyUserContext @@ -298,7 +300,7 @@ def decide_all(user_context, decide_options = []) decide_for_keys(user_context, keys, decide_options) end - def decide_for_keys(user_context, keys, decide_options = []) + def decide_for_keys(user_context, keys, decide_options = [], ignore_default_options = false) # rubocop:disable Style/OptionalBooleanParameter # raising on user context as it is internal and not provided directly by the user. raise if user_context.class != OptimizelyUserContext @@ -308,13 +310,79 @@ def decide_for_keys(user_context, keys, decide_options = []) return {} end - enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY) + # merge decide_options and default_decide_options + unless ignore_default_options + if decide_options.is_a?(Array) + decide_options += @default_decide_options + else + @logger.log(Logger::DEBUG, 'Provided decide options is not an array. Using default decide options.') + decide_options = @default_decide_options + end + end + + # enabled_flags_only = (!decide_options.nil? && (decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY)) || (@default_decide_options.include? OptimizelyDecideOption::ENABLED_FLAGS_ONLY) decisions = {} + valid_keys = [] + decision_reasons_dict = {} + config = project_config + return decisions unless config + + flags_without_forced_decision = [] + flag_decisions = {} + keys.each do |key| - decision = decide(user_context, key, decide_options) - decisions[key] = decision unless enabled_flags_only && !decision.enabled + # Retrieve the feature flag from the project's feature flag key map + feature_flag = config.feature_flag_key_map[key] + + # If the feature flag is nil, create a default OptimizelyDecision and move to the next key + if feature_flag.nil? + decisions[key] = OptimizelyDecision.new(nil, false, nil, nil, key, user_context, []) + next + end + valid_keys.push(key) + decision_reasons = [] + decision_reasons_dict[key] = decision_reasons + + config = project_config + context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(key, nil) + variation, reasons_received = @decision_service.validated_forced_decision(config, context, user_context) + decision_reasons_dict[key].push(*reasons_received) + if variation + decision = Optimizely::DecisionService::Decision.new(nil, variation, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST']) + flag_decisions[key] = decision + else + flags_without_forced_decision.push(feature_flag) + end end + decision_list = @decision_service.get_variations_for_feature_list(config, flags_without_forced_decision, user_context, decide_options) + + flags_without_forced_decision.each_with_index do |flag, i| + decision = decision_list[i][0] + reasons = decision_list[i][1] + flag_key = flag['key'] + flag_decisions[flag_key] = decision + decision_reasons_dict[flag_key] ||= [] + decision_reasons_dict[flag_key].push(*reasons) + end + valid_keys.each do |key| + flag_decision = flag_decisions[key] + decision_reasons = decision_reasons_dict[key] + optimizely_decision = create_optimizely_decision( + user_context, + key, + flag_decision, + decision_reasons, + decide_options, + config + ) + + enabled_flags_only_missing = !decide_options.include?(OptimizelyDecideOption::ENABLED_FLAGS_ONLY) + is_enabled = optimizely_decision.enabled + + decisions[key] = optimizely_decision if enabled_flags_only_missing || is_enabled + end + decisions end @@ -564,7 +632,7 @@ def is_feature_enabled(feature_flag_key, user_id, attributes = nil) @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE'], - user_id, (attributes || {}), + user_id, attributes || {}, feature_key: feature_flag_key, feature_enabled: feature_enabled, source: source_string, @@ -792,7 +860,7 @@ def get_all_feature_variables(feature_flag_key, user_id, attributes = nil) @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], - Helpers::Constants::DECISION_NOTIFICATION_TYPES['ALL_FEATURE_VARIABLES'], user_id, (attributes || {}), + Helpers::Constants::DECISION_NOTIFICATION_TYPES['ALL_FEATURE_VARIABLES'], user_id, attributes || {}, feature_key: feature_flag_key, feature_enabled: feature_enabled, source: source_string, @@ -889,7 +957,7 @@ def get_optimizely_config if @config_manager.respond_to?(:optimizely_config) @config_manager.optimizely_config else - OptimizelyConfig.new(project_config).config + OptimizelyConfig.new(project_config, @logger).config end end @@ -959,7 +1027,10 @@ def get_variation_with_config(experiment_key, user_id, attributes, config) return nil unless user_inputs_valid?(attributes) user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false) - variation_id, = @decision_service.get_variation(config, experiment_id, user_context) + user_profile_tracker = UserProfileTracker.new(user_id, @user_profile_service, @logger) + user_profile_tracker.load_user_profile + variation_id, = @decision_service.get_variation(config, experiment_id, user_context, user_profile_tracker) + user_profile_tracker.save_user_profile variation = config.get_variation_from_id(experiment_key, variation_id) unless variation_id.nil? variation_key = variation['key'] if variation decision_notification_type = if config.feature_experiment?(experiment_id) @@ -969,7 +1040,7 @@ def get_variation_with_config(experiment_key, user_id, attributes, config) end @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], - decision_notification_type, user_id, (attributes || {}), + decision_notification_type, user_id, attributes || {}, experiment_key: experiment_key, variation_key: variation_key ) @@ -1044,7 +1115,7 @@ def get_feature_variable_for_type(feature_flag_key, variable_key, variable_type, @notification_center.send_notifications( NotificationCenter::NOTIFICATION_TYPES[:DECISION], - Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE_VARIABLE'], user_id, (attributes || {}), + Helpers::Constants::DECISION_NOTIFICATION_TYPES['FEATURE_VARIABLE'], user_id, attributes || {}, feature_key: feature_flag_key, feature_enabled: feature_enabled, source: source_string, diff --git a/lib/optimizely/audience.rb b/lib/optimizely/audience.rb index 4c57261a..3e919ad8 100644 --- a/lib/optimizely/audience.rb +++ b/lib/optimizely/audience.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2016-2017, 2019-2020, Optimizely and contributors +# Copyright 2016-2017, 2019-2020, 2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -59,7 +59,7 @@ def user_meets_audience_conditions?(config, experiment, user_context, logger, lo user_condition_evaluator = UserConditionEvaluator.new(user_context, logger) evaluate_user_conditions = lambda do |condition| - return user_condition_evaluator.evaluate(condition) + user_condition_evaluator.evaluate(condition) end evaluate_audience = lambda do |audience_id| diff --git a/lib/optimizely/bucketer.rb b/lib/optimizely/bucketer.rb index ba502833..15f711cb 100644 --- a/lib/optimizely/bucketer.rb +++ b/lib/optimizely/bucketer.rb @@ -110,8 +110,8 @@ def find_bucket(bucketing_id, user_id, parent_id, traffic_allocations) # parent_id - String entity ID to use for bucketing ID # traffic_allocations - Array of traffic allocations # - # Returns and array of two values where first value is the entity ID corresponding to the provided bucket value - # or nil if no match is found. The second value contains the array of reasons stating how the deicision was taken + # Returns an array of two values where first value is the entity ID corresponding to the provided bucket value + # or nil if no match is found. The second value contains the array of reasons stating how the decision was taken decide_reasons = [] bucketing_key = format(BUCKETING_ID_TEMPLATE, bucketing_id: bucketing_id, entity_id: parent_id) bucket_value = generate_bucket_value(bucketing_key) diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index d8d78975..25357133 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -223,8 +223,9 @@ def get_experiment_from_key(experiment_key) experiment = @experiment_key_map[experiment_key] return experiment if experiment - @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile." - @error_handler.handle_error InvalidExperimentError + invalid_experiment_error = InvalidExperimentError.new(experiment_key: experiment_key) + @logger.log Logger::ERROR, invalid_experiment_error.message + @error_handler.handle_error invalid_experiment_error nil end @@ -238,8 +239,9 @@ def get_experiment_from_id(experiment_id) experiment = @experiment_id_map[experiment_id] return experiment if experiment - @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile." - @error_handler.handle_error InvalidExperimentError + invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id) + @logger.log Logger::ERROR, invalid_experiment_error.message + @error_handler.handle_error invalid_experiment_error nil end @@ -253,8 +255,9 @@ def get_experiment_key(experiment_id) experiment = @experiment_id_map[experiment_id] return experiment['key'] unless experiment.nil? - @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile." - @error_handler.handle_error InvalidExperimentError + invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id) + @logger.log Logger::ERROR, invalid_experiment_error.message + @error_handler.handle_error invalid_experiment_error nil end @@ -268,8 +271,9 @@ def get_event_from_key(event_key) event = @event_key_map[event_key] return event if event - @logger.log Logger::ERROR, "Event '#{event_key}' is not in datafile." - @error_handler.handle_error InvalidEventError + invalid_event_error = InvalidEventError.new(event_key) + @logger.log Logger::ERROR, invalid_event_error.message + @error_handler.handle_error invalid_event_error nil end @@ -283,8 +287,9 @@ def get_audience_from_id(audience_id) audience = @audience_id_map[audience_id] return audience if audience - @logger.log Logger::ERROR, "Audience '#{audience_id}' is not in datafile." - @error_handler.handle_error InvalidAudienceError + invalid_audience_error = InvalidAudienceError.new(audience_id) + @logger.log Logger::ERROR, invalid_audience_error.message + @error_handler.handle_error invalid_audience_error nil end @@ -308,13 +313,15 @@ def get_variation_from_id(experiment_key, variation_id) variation = variation_id_map[variation_id] return variation if variation - @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile." - @error_handler.handle_error InvalidVariationError + invalid_variation_error = InvalidVariationError.new(variation_id: variation_id) + @logger.log Logger::ERROR, invalid_variation_error.message + @error_handler.handle_error invalid_variation_error return nil end - @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile." - @error_handler.handle_error InvalidExperimentError + invalid_experiment_error = InvalidExperimentError.new(experiment_key: experiment_key) + @logger.log Logger::ERROR, invalid_experiment_error.message + @error_handler.handle_error invalid_experiment_error nil end @@ -331,13 +338,15 @@ def get_variation_from_id_by_experiment_id(experiment_id, variation_id) variation = variation_id_map_by_experiment_id[variation_id] return variation if variation - @logger.log Logger::ERROR, "Variation id '#{variation_id}' is not in datafile." - @error_handler.handle_error InvalidVariationError + invalid_variation_error = InvalidVariationError.new(variation_id: variation_id) + @logger.log Logger::ERROR, invalid_variation_error.message + @error_handler.handle_error invalid_variation_error return nil end - @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile." - @error_handler.handle_error InvalidExperimentError + invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id) + @logger.log Logger::ERROR, invalid_experiment_error.message + @error_handler.handle_error invalid_experiment_error nil end @@ -354,13 +363,15 @@ def get_variation_id_from_key_by_experiment_id(experiment_id, variation_key) variation = variation_key_map[variation_key] return variation['id'] if variation - @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile." - @error_handler.handle_error InvalidVariationError + invalid_variation_error = InvalidVariationError.new(variation_key: variation_key) + @logger.log Logger::ERROR, invalid_variation_error.message + @error_handler.handle_error invalid_variation_error return nil end - @logger.log Logger::ERROR, "Experiment id '#{experiment_id}' is not in datafile." - @error_handler.handle_error InvalidExperimentError + invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id) + @logger.log Logger::ERROR, invalid_experiment_error.message + @error_handler.handle_error invalid_experiment_error nil end @@ -377,13 +388,15 @@ def get_variation_id_from_key(experiment_key, variation_key) variation = variation_key_map[variation_key] return variation['id'] if variation - @logger.log Logger::ERROR, "Variation key '#{variation_key}' is not in datafile." - @error_handler.handle_error InvalidVariationError + invalid_variation_error = InvalidVariationError.new(variation_key: variation_key) + @logger.log Logger::ERROR, invalid_variation_error.message + @error_handler.handle_error invalid_variation_error return nil end - @logger.log Logger::ERROR, "Experiment key '#{experiment_key}' is not in datafile." - @error_handler.handle_error InvalidExperimentError + invalid_experiment_error = InvalidExperimentError.new(experiment_key: experiment_key) + @logger.log Logger::ERROR, invalid_experiment_error.message + @error_handler.handle_error invalid_experiment_error nil end @@ -397,8 +410,9 @@ def get_whitelisted_variations(experiment_id) experiment = @experiment_id_map[experiment_id] return experiment['forcedVariations'] if experiment - @logger.log Logger::ERROR, "Experiment ID '#{experiment_id}' is not in datafile." - @error_handler.handle_error InvalidExperimentError + invalid_experiment_error = InvalidExperimentError.new(experiment_id: experiment_id) + @logger.log Logger::ERROR, invalid_experiment_error.message + @error_handler.handle_error invalid_experiment_error end def get_attribute_id(attribute_key) @@ -420,8 +434,9 @@ def get_attribute_id(attribute_key) end return attribute_key if has_reserved_prefix - @logger.log Logger::ERROR, "Attribute key '#{attribute_key}' is not in datafile." - @error_handler.handle_error InvalidAttributeError + invalid_attribute_error = InvalidAttributeError.new(attribute_key) + @logger.log Logger::ERROR, invalid_attribute_error.message + @error_handler.handle_error invalid_attribute_error nil end @@ -439,8 +454,9 @@ def variation_id_exists?(experiment_id, variation_id) variation = variation_id_map[variation_id] return true if variation - @logger.log Logger::ERROR, "Variation ID '#{variation_id}' is not in datafile." - @error_handler.handle_error InvalidVariationError + invalid_variation_error = InvalidVariationError.new(variation_id: variation_id) + @logger.log Logger::ERROR, invalid_variation_error.message + @error_handler.handle_error invalid_variation_error end false diff --git a/lib/optimizely/config_manager/http_project_config_manager.rb b/lib/optimizely/config_manager/http_project_config_manager.rb index 0da73c1f..03e177b5 100644 --- a/lib/optimizely/config_manager/http_project_config_manager.rb +++ b/lib/optimizely/config_manager/http_project_config_manager.rb @@ -102,11 +102,6 @@ def ready? end def start! - if @stopped - @logger.log(Logger::WARN, 'Not starting. Already stopped.') - return - end - @async_scheduler.start! @stopped = false end @@ -146,7 +141,7 @@ def config end def optimizely_config - @optimizely_config = OptimizelyConfig.new(@config).config if @optimizely_config.nil? + @optimizely_config = OptimizelyConfig.new(@config, @logger).config if @optimizely_config.nil? @optimizely_config end @@ -268,6 +263,13 @@ def polling_interval(polling_interval) return end + if polling_interval < 30 + @logger.log( + Logger::WARN, + 'Polling intervals below 30 seconds are not recommended.' + ) + end + @polling_interval = polling_interval end diff --git a/lib/optimizely/config_manager/static_project_config_manager.rb b/lib/optimizely/config_manager/static_project_config_manager.rb index 38829ce4..200126f8 100644 --- a/lib/optimizely/config_manager/static_project_config_manager.rb +++ b/lib/optimizely/config_manager/static_project_config_manager.rb @@ -41,12 +41,13 @@ def initialize(datafile, logger, error_handler, skip_json_validation) error_handler, skip_json_validation ) + @logger = logger @sdk_key = @config&.sdk_key @optimizely_config = nil end def optimizely_config - @optimizely_config = OptimizelyConfig.new(@config).config if @optimizely_config.nil? + @optimizely_config = OptimizelyConfig.new(@config, @logger).config if @optimizely_config.nil? @optimizely_config end diff --git a/lib/optimizely/decision_service.rb b/lib/optimizely/decision_service.rb index 3dbbf1d0..3303907d 100644 --- a/lib/optimizely/decision_service.rb +++ b/lib/optimizely/decision_service.rb @@ -52,17 +52,20 @@ def initialize(logger, user_profile_service = nil) @forced_variation_map = {} end - def get_variation(project_config, experiment_id, user_context, decide_options = []) + def get_variation(project_config, experiment_id, user_context, user_profile_tracker = nil, decide_options = [], reasons = []) # Determines variation into which user will be bucketed. # # project_config - project_config - Instance of ProjectConfig # experiment_id - Experiment for which visitor variation needs to be determined # user_context - Optimizely user context instance + # user_profile_tracker: Tracker for reading and updating user profile of the user. + # reasons: Decision reasons. # # Returns variation ID where visitor will be bucketed # (nil if experiment is inactive or user does not meet audience conditions) - + user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger) unless user_profile_tracker.is_a?(Optimizely::UserProfileTracker) decide_reasons = [] + decide_reasons.push(*reasons) user_id = user_context.user_id attributes = user_context.user_attributes # By default, the bucketing ID should be the user ID @@ -92,10 +95,8 @@ def get_variation(project_config, experiment_id, user_context, decide_options = should_ignore_user_profile_service = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE # Check for saved bucketing decisions if decide_options do not include ignoreUserProfileService - unless should_ignore_user_profile_service - user_profile, reasons_received = get_user_profile(user_id) - decide_reasons.push(*reasons_received) - saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile) + unless should_ignore_user_profile_service && user_profile_tracker + saved_variation_id, reasons_received = get_saved_variation_id(project_config, experiment_id, user_profile_tracker.user_profile) decide_reasons.push(*reasons_received) if saved_variation_id message = "Returning previously activated variation ID #{saved_variation_id} of experiment '#{experiment_key}' for user '#{user_id}' from user profile." @@ -131,7 +132,7 @@ def get_variation(project_config, experiment_id, user_context, decide_options = decide_reasons.push(message) # Persist bucketing decision - save_user_profile(user_profile, experiment_id, variation_id) unless should_ignore_user_profile_service + user_profile_tracker.update_user_profile(experiment_id, variation_id) unless should_ignore_user_profile_service && user_profile_tracker [variation_id, decide_reasons] end @@ -143,21 +144,46 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide # user_context - Optimizely user context instance # # Returns Decision struct (nil if the user is not bucketed into any of the experiments on the feature) + get_variations_for_feature_list(project_config, [feature_flag], user_context, decide_options).first + end - decide_reasons = [] - - # check if the feature is being experiment on and whether the user is bucketed into the experiment - decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options) - decide_reasons.push(*reasons_received) - return decision, decide_reasons unless decision.nil? - - decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context) - decide_reasons.push(*reasons_received) - - [decision, decide_reasons] + def get_variations_for_feature_list(project_config, feature_flags, user_context, decide_options = []) + # Returns the list of experiment/variation the user is bucketed in for the given list of features. + # + # Args: + # project_config: Instance of ProjectConfig. + # feature_flags: Array of features for which we are determining if it is enabled or not for the given user. + # user_context: User context for user. + # decide_options: Decide options. + # + # Returns: + # Array of Decision struct. + ignore_ups = decide_options.include? Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE + user_profile_tracker = nil + unless ignore_ups && @user_profile_service + user_profile_tracker = UserProfileTracker.new(user_context.user_id, @user_profile_service, @logger) + user_profile_tracker.load_user_profile + end + decisions = [] + feature_flags.each do |feature_flag| + decide_reasons = [] + # check if the feature is being experiment on and whether the user is bucketed into the experiment + decision, reasons_received = get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options) + decide_reasons.push(*reasons_received) + if decision + decisions << [decision, decide_reasons] + else + # Proceed to rollout if the decision is nil + rollout_decision, reasons_received = get_variation_for_feature_rollout(project_config, feature_flag, user_context) + decide_reasons.push(*reasons_received) + decisions << [rollout_decision, decide_reasons] + end + end + user_profile_tracker&.save_user_profile + decisions end - def get_variation_for_feature_experiment(project_config, feature_flag, user_context, decide_options = []) + def get_variation_for_feature_experiment(project_config, feature_flag, user_context, user_profile_tracker, decide_options = []) # Gets the variation the user is bucketed into for the feature flag's experiment. # # project_config - project_config - Instance of ProjectConfig @@ -187,7 +213,7 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont end experiment_id = experiment['id'] - variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, decide_options) + variation_id, reasons_received = get_variation_from_experiment_rule(project_config, feature_flag_key, experiment, user_context, user_profile_tracker, decide_options) decide_reasons.push(*reasons_received) next unless variation_id @@ -252,7 +278,7 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context [nil, decide_reasons] end - def get_variation_from_experiment_rule(project_config, flag_key, rule, user, options = []) + def get_variation_from_experiment_rule(project_config, flag_key, rule, user, user_profile_tracker, options = []) # Determine which variation the user is in for a given rollout. # Returns the variation from experiment rules. # @@ -270,7 +296,7 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, opt return [variation['id'], reasons] if variation - variation_id, response_reasons = get_variation(project_config, rule['id'], user, options) + variation_id, response_reasons = get_variation(project_config, rule['id'], user, user_profile_tracker, options) reasons.push(*response_reasons) [variation_id, reasons] diff --git a/lib/optimizely/event/event_factory.rb b/lib/optimizely/event/event_factory.rb index d8d5062e..9ac8a937 100644 --- a/lib/optimizely/event/event_factory.rb +++ b/lib/optimizely/event/event_factory.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2019-2020, 2022, Optimizely and contributors +# Copyright 2019-2020, 2022-2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -72,7 +72,7 @@ def create_log_event(user_events, logger) def build_attribute_list(user_attributes, project_config) visitor_attributes = [] - user_attributes&.keys&.each do |attribute_key| + user_attributes&.each_key do |attribute_key| # Omit attribute values that are not supported by the log endpoint. attribute_value = user_attributes[attribute_key] next unless Helpers::Validator.attribute_valid?(attribute_key, attribute_value) diff --git a/lib/optimizely/event_builder.rb b/lib/optimizely/event_builder.rb index 9b87413e..4c743cc3 100644 --- a/lib/optimizely/event_builder.rb +++ b/lib/optimizely/event_builder.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2016-2019, 2022, Optimizely and contributors +# Copyright 2016-2019, 2022-2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -62,23 +62,23 @@ def get_common_params(project_config, user_id, attributes) visitor_attributes = [] - attributes&.keys&.each do |attribute_key| + attributes&.each_key do |attribute_key| # Omit attribute values that are not supported by the log endpoint. attribute_value = attributes[attribute_key] - if Helpers::Validator.attribute_valid?(attribute_key, attribute_value) - attribute_id = project_config.get_attribute_id attribute_key - if attribute_id - visitor_attributes.push( - entity_id: attribute_id, - key: attribute_key, - type: CUSTOM_ATTRIBUTE_FEATURE_TYPE, - value: attribute_value - ) - end - end + next unless Helpers::Validator.attribute_valid?(attribute_key, attribute_value) + + attribute_id = project_config.get_attribute_id attribute_key + next unless attribute_id + + visitor_attributes.push( + entity_id: attribute_id, + key: attribute_key, + type: CUSTOM_ATTRIBUTE_FEATURE_TYPE, + value: attribute_value + ) end # Append Bot Filtering Attribute - if project_config.bot_filtering == true || project_config.bot_filtering == false + if [true, false].include?(project_config.bot_filtering) visitor_attributes.push( entity_id: Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BOT_FILTERING'], key: Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BOT_FILTERING'], diff --git a/lib/optimizely/exceptions.rb b/lib/optimizely/exceptions.rb index 50ef62c0..5d608b2f 100644 --- a/lib/optimizely/exceptions.rb +++ b/lib/optimizely/exceptions.rb @@ -42,16 +42,28 @@ def initialize(msg = 'SDK key not provided/cannot be found in the datafile.') class InvalidAudienceError < Error # Raised when an invalid audience is provided - def initialize(msg = 'Provided audience is not in datafile.') - super + attr_reader :audience_id + + def initialize(audience_id) + raise ArgumentError, 'audience_id must be provided' if audience_id.nil? + + super("Audience id '#{audience_id}' is not in datafile.") + + @audience_id = audience_id end end class InvalidAttributeError < Error # Raised when an invalid attribute is provided - def initialize(msg = 'Provided attribute is not in datafile.') - super + attr_reader :attribute_key + + def initialize(attribute_key) + raise ArgumentError, 'attribute_key must be provided' if attribute_key.nil? + + super("Attribute key '#{attribute_key}' is not in datafile.") + + @attribute_key = attribute_key end end @@ -74,24 +86,56 @@ def initialize(msg = 'Event tags provided are in an invalid format.') class InvalidExperimentError < Error # Raised when an invalid experiment key is provided - def initialize(msg = 'Provided experiment is not in datafile.') - super + attr_reader :experiment_id, :experiment_key + + def initialize(experiment_id: nil, experiment_key: nil) + raise ArgumentError, 'Either experiment_id or experiment_key must be provided.' if experiment_id.nil? && experiment_key.nil? + raise ArgumentError, 'Cannot provide both experiment_id and experiment_key.' if !experiment_id.nil? && !experiment_key.nil? + + if experiment_id.nil? + @experiment_key = experiment_key + identifier = "key '#{@experiment_key}'" + else + @experiment_id = experiment_id + identifier = "id '#{@experiment_id}'" + end + + super("Experiment #{identifier} is not in datafile.") end end class InvalidEventError < Error # Raised when an invalid event key is provided - def initialize(msg = 'Provided event is not in datafile.') - super + attr_reader :event_key + + def initialize(event_key) + raise ArgumentError, 'event_key must be provided.' if event_key.nil? + + super("Event key '#{event_key}' is not in datafile.") + + @event_key = event_key end end class InvalidVariationError < Error # Raised when an invalid variation key or ID is provided - def initialize(msg = 'Provided variation is not in datafile.') - super + attr_reader :variation_id, :variation_key + + def initialize(variation_id: nil, variation_key: nil) + raise ArgumentError, 'Either variation_id or variation_key must be provided.' if variation_id.nil? && variation_key.nil? + raise ArgumentError, 'Cannot provide both variation_id and variation_key.' if !variation_id.nil? && !variation_key.nil? + + if variation_id.nil? + identifier = "key '#{variation_key}'" + @variation_key = variation_key + else + identifier = "id '#{variation_id}'" + @variation_id = variation_id + end + + super("Variation #{identifier} is not in datafile.") end end diff --git a/lib/optimizely/helpers/validator.rb b/lib/optimizely/helpers/validator.rb index 3ae2350a..d3baa447 100644 --- a/lib/optimizely/helpers/validator.rb +++ b/lib/optimizely/helpers/validator.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2016-2019, 2022, Optimizely and contributors +# Copyright 2016-2019, 2022-2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -190,14 +190,13 @@ def segments_cache_valid?(segments_cache) # segments_cache - custom cache to be validated. # # Returns boolean depending on whether cache has required methods. - ( - segments_cache.respond_to?(:reset) && + + segments_cache.respond_to?(:reset) && segments_cache.method(:reset)&.parameters&.empty? && segments_cache.respond_to?(:lookup) && segments_cache.method(:lookup)&.parameters&.length&.positive? && segments_cache.respond_to?(:save) && segments_cache.method(:save)&.parameters&.length&.positive? - ) end def segment_manager_valid?(segment_manager) @@ -206,13 +205,12 @@ def segment_manager_valid?(segment_manager) # segment_manager - custom manager to be validated. # # Returns boolean depending on whether manager has required methods. - ( - segment_manager.respond_to?(:odp_config) && + + segment_manager.respond_to?(:odp_config) && segment_manager.respond_to?(:reset) && segment_manager.method(:reset)&.parameters&.empty? && segment_manager.respond_to?(:fetch_qualified_segments) && (segment_manager.method(:fetch_qualified_segments)&.parameters&.length || 0) >= 3 - ) end def event_manager_valid?(event_manager) diff --git a/lib/optimizely/optimizely_config.rb b/lib/optimizely/optimizely_config.rb index 1ffbcd94..32a637ad 100644 --- a/lib/optimizely/optimizely_config.rb +++ b/lib/optimizely/optimizely_config.rb @@ -19,8 +19,9 @@ module Optimizely require 'json' class OptimizelyConfig include Optimizely::ConditionTreeEvaluator - def initialize(project_config) + def initialize(project_config, logger = nil) @project_config = project_config + @logger = logger || NoOpLogger.new @rollouts = @project_config.rollouts @audiences = [] audience_id_lookup_dict = {} @@ -91,6 +92,7 @@ def audiences_map def experiments_map experiments_id_map.values.reduce({}) do |experiments_key_map, experiment| + @logger.log(Logger::WARN, "Duplicate experiment keys found in datafile: #{experiment['key']}") if experiments_key_map.key? experiment['key'] experiments_key_map.update(experiment['key'] => experiment) end end diff --git a/lib/optimizely/optimizely_factory.rb b/lib/optimizely/optimizely_factory.rb index b6734872..717e43d9 100644 --- a/lib/optimizely/optimizely_factory.rb +++ b/lib/optimizely/optimizely_factory.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2019, 2022, Optimizely and contributors +# Copyright 2019, 2022-2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -103,7 +103,7 @@ def self.default_instance(sdk_key, datafile = nil) ) Optimizely::Project.new( - datafile, nil, logger, error_handler, nil, nil, sdk_key, config_manager, notification_center + datafile: datafile, logger: logger, error_handler: error_handler, sdk_key: sdk_key, config_manager: config_manager, notification_center: notification_center ) end @@ -111,7 +111,7 @@ def self.default_instance(sdk_key, datafile = nil) # # @param config_manager - Required ConfigManagerInterface Responds to 'config' method. def self.default_instance_with_config_manager(config_manager) - Optimizely::Project.new(nil, nil, nil, nil, nil, nil, nil, config_manager) + Optimizely::Project.new(config_manager: config_manager) end # Returns a new optimizely instance. @@ -142,7 +142,6 @@ def self.custom_instance( # rubocop:disable Metrics/ParameterLists notification_center = nil, settings = nil ) - error_handler ||= NoOpErrorHandler.new logger ||= NoOpLogger.new notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(logger, error_handler) @@ -167,19 +166,17 @@ def self.custom_instance( # rubocop:disable Metrics/ParameterLists ) Optimizely::Project.new( - datafile, - event_dispatcher, - logger, - error_handler, - skip_json_validation, - user_profile_service, - sdk_key, - config_manager, - notification_center, - event_processor, - [], - {}, - settings + datafile: datafile, + event_dispatcher: event_dispatcher, + logger: logger, + error_handler: error_handler, + skip_json_validation: skip_json_validation, + user_profile_service: user_profile_service, + sdk_key: sdk_key, + config_manager: config_manager, + notification_center: notification_center, + event_processor: event_processor, + settings: settings ) end end diff --git a/lib/optimizely/user_profile_tracker.rb b/lib/optimizely/user_profile_tracker.rb new file mode 100644 index 00000000..082576b0 --- /dev/null +++ b/lib/optimizely/user_profile_tracker.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative 'logger' + +module Optimizely + class UserProfileTracker + attr_reader :user_profile + + def initialize(user_id, user_profile_service = nil, logger = nil) + @user_id = user_id + @user_profile_service = user_profile_service + @logger = logger || NoOpLogger.new + @profile_updated = false + @user_profile = { + user_id: user_id, + experiment_bucket_map: {} + } + end + + def load_user_profile(reasons = [], error_handler = nil) + return if reasons.nil? + + begin + @user_profile = @user_profile_service.lookup(@user_id) if @user_profile_service + if @user_profile.nil? + @user_profile = { + user_id: @user_id, + experiment_bucket_map: {} + } + end + rescue => e + message = "Error while looking up user profile for user ID '#{@user_id}': #{e}." + reasons << message + @logger.log(Logger::ERROR, message) + error_handler&.handle_error(e) + end + end + + def update_user_profile(experiment_id, variation_id) + user_id = @user_profile[:user_id] + begin + @user_profile[:experiment_bucket_map][experiment_id] = { + variation_id: variation_id + } + @profile_updated = true + @logger.log(Logger::INFO, "Updated variation ID #{variation_id} of experiment ID #{experiment_id} for user '#{user_id}'.") + rescue => e + @logger.log(Logger::ERROR, "Error while updating user profile for user ID '#{user_id}': #{e}.") + end + end + + def save_user_profile(error_handler = nil) + return unless @profile_updated && @user_profile_service + + begin + @user_profile_service.save(@user_profile) + @logger.log(Logger::INFO, "Saved user profile for user '#{@user_profile[:user_id]}'.") + rescue => e + @logger.log(Logger::ERROR, "Failed to save user profile for user '#{@user_profile[:user_id]}': #{e}.") + error_handler&.handle_error(e) + end + end + end +end diff --git a/lib/optimizely/version.rb b/lib/optimizely/version.rb index 43d4f749..27894065 100644 --- a/lib/optimizely/version.rb +++ b/lib/optimizely/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2016-2023, Optimizely and contributors +# Copyright 2016-2024, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,5 +17,5 @@ # module Optimizely CLIENT_ENGINE = 'ruby-sdk' - VERSION = '5.0.0-beta' + VERSION = '5.1.0' end diff --git a/optimizely-sdk.gemspec b/optimizely-sdk.gemspec index c1c5b881..2a3c87c5 100644 --- a/optimizely-sdk.gemspec +++ b/optimizely-sdk.gemspec @@ -3,19 +3,23 @@ require_relative 'lib/optimizely/version' Gem::Specification.new do |spec| - spec.name = 'optimizely-sdk' - spec.version = Optimizely::VERSION - spec.authors = ['Optimizely'] - spec.email = ['developers@optimizely.com'] - spec.required_ruby_version = '>= 2.7' + spec.name = 'optimizely-sdk' + spec.version = Optimizely::VERSION + spec.authors = ['Optimizely'] + spec.email = ['developers@optimizely.com'] + spec.required_ruby_version = '>= 3.0' - spec.summary = "Ruby SDK for Optimizely's testing framework" - spec.description = 'A Ruby SDK for use with Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts' - spec.homepage = 'https://www.optimizely.com/' - spec.license = 'Apache-2.0' + spec.summary = "Ruby SDK for Optimizely's testing framework" + spec.description = 'A Ruby SDK for use with Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts' + spec.homepage = 'https://github.com/optimizely/ruby-sdk' + spec.license = 'Apache-2.0' + spec.metadata = { + 'source_code_uri' => 'https://github.com/optimizely/ruby-sdk', + 'changelog_uri' => 'https://github.com/optimizely/ruby-sdk/blob/master/CHANGELOG.md' + } - spec.files = Dir['lib/**/*', 'LICENSE'] - spec.require_paths = ['lib'] + spec.files = Dir['lib/**/*', 'LICENSE'] + spec.require_paths = ['lib'] spec.add_development_dependency 'bundler' spec.add_development_dependency 'coveralls_reborn' @@ -24,6 +28,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rubocop' spec.add_development_dependency 'webmock' - spec.add_runtime_dependency 'json-schema', '~> 2.6' + spec.add_runtime_dependency 'json-schema', '>= 2.6' spec.add_runtime_dependency 'murmurhash3', '~> 0.1' end diff --git a/spec/audience_spec.rb b/spec/audience_spec.rb index 73560aff..eb997f0e 100644 --- a/spec/audience_spec.rb +++ b/spec/audience_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2016-2017, 2019-2020, 2022, Optimizely and contributors +# Copyright 2016-2017, 2019-2020, 2022-2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -25,7 +25,7 @@ let(:config) { Optimizely::DatafileProjectConfig.new(config_body_JSON, spy_logger, error_handler) } let(:typed_audience_config) { Optimizely::DatafileProjectConfig.new(config_typed_audience_JSON, spy_logger, error_handler) } let(:integration_config) { Optimizely::DatafileProjectConfig.new(config_integration_JSON, spy_logger, error_handler) } - let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) } + let(:project_instance) { Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler) } let(:user_context) { project_instance.create_user_context('some-user', {}) } after(:example) { project_instance.close } @@ -47,7 +47,7 @@ user_meets_audience_conditions, reasons = Optimizely::Audience.user_meets_audience_conditions?(config, experiment, user_context, spy_logger) expect(user_meets_audience_conditions).to be true - expect(reasons).to eq(["Audiences for experiment 'test_experiment' collectively evaluated to TRUE."]) + expect(reasons).to eq(["Audiences for experiment 'test_experiment' collectively evaluated to TRUE."]) # Audience Ids is Empty and Audience Conditions is nil experiment = config.experiment_key_map['test_experiment'] diff --git a/spec/condition_tree_evaluator_spec.rb b/spec/condition_tree_evaluator_spec.rb index 68e99844..28dda143 100644 --- a/spec/condition_tree_evaluator_spec.rb +++ b/spec/condition_tree_evaluator_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2019, Optimizely and contributors +# Copyright 2019, 2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -27,19 +27,19 @@ describe 'evaluate' do it 'should return true for a leaf condition when the leaf condition evaluator returns true' do - leaf_callback = ->(_condition) { return true } + leaf_callback = ->(_condition) { true } expect(Optimizely::ConditionTreeEvaluator.evaluate(@browser_condition, leaf_callback)).to be true end it 'should return false for a leaf condition when the leaf condition evaluator returns false' do - leaf_callback = ->(_condition) { return false } + leaf_callback = ->(_condition) { false } expect(Optimizely::ConditionTreeEvaluator.evaluate(@browser_condition, leaf_callback)).to be false end end describe 'and evaluation' do it 'should return true when ALL conditions evaluate to true' do - leaf_callback = ->(_condition) { return true } + leaf_callback = ->(_condition) { true } expect(Optimizely::ConditionTreeEvaluator.evaluate(['and', @browser_condition, @device_condition], leaf_callback)).to be true end @@ -51,7 +51,7 @@ describe 'nil handling' do it 'should return nil when all operands evaluate to nil' do - leaf_callback = ->(_condition) { return nil } + leaf_callback = ->(_condition) { nil } expect(Optimizely::ConditionTreeEvaluator.evaluate(['and', @browser_condition, @device_condition], leaf_callback)).to eq(nil) end @@ -83,7 +83,7 @@ describe 'or evaluation' do it 'should return false if all conditions evaluate to false' do - leaf_callback = ->(_condition) { return false } + leaf_callback = ->(_condition) { false } expect(Optimizely::ConditionTreeEvaluator.evaluate(['or', @browser_condition, @device_condition], leaf_callback)).to be false end @@ -95,7 +95,7 @@ describe 'nil handling' do it 'should return nil when all operands evaluate to nil' do - leaf_callback = ->(_condition) { return nil } + leaf_callback = ->(_condition) { nil } expect(Optimizely::ConditionTreeEvaluator.evaluate(['or', @browser_condition, @device_condition], leaf_callback)).to eq(nil) end @@ -127,34 +127,34 @@ describe 'not evaluation' do it 'should return true if the condition evaluates to false' do - leaf_callback = ->(_condition) { return false } + leaf_callback = ->(_condition) { false } expect(Optimizely::ConditionTreeEvaluator.evaluate(['not', @browser_condition], leaf_callback)).to be true end it 'should return false if the condition evaluates to true' do - leaf_callback = ->(_condition) { return true } + leaf_callback = ->(_condition) { true } expect(Optimizely::ConditionTreeEvaluator.evaluate(['not', @browser_condition], leaf_callback)).to be false end it 'should return the result of negating the first condition, and ignore any additional conditions' do - leaf_callback = ->(id) { return id == '1' } + leaf_callback = ->(id) { id == '1' } expect(Optimizely::ConditionTreeEvaluator.evaluate(%w[not 1 2 1], leaf_callback)).to be false - leaf_callback2 = ->(id) { return id == '2' } + leaf_callback2 = ->(id) { id == '2' } expect(Optimizely::ConditionTreeEvaluator.evaluate(%w[not 1 2 1], leaf_callback2)).to be true - leaf_callback3 = ->(id) { return id == '1' ? nil : id == '3' } + leaf_callback3 = ->(id) { id == '1' ? nil : id == '3' } expect(Optimizely::ConditionTreeEvaluator.evaluate(%w[not 1 2 3], leaf_callback3)).to eq(nil) end describe 'nil handling' do it 'should return nil when operand evaluates to nil' do - leaf_callback = ->(_condition) { return nil } + leaf_callback = ->(_condition) { nil } expect(Optimizely::ConditionTreeEvaluator.evaluate(['not', @browser_condition, @device_condition], leaf_callback)).to eq(nil) end it 'should return nil when there are no operands' do - leaf_callback = ->(_condition) { return nil } + leaf_callback = ->(_condition) { nil } expect(Optimizely::ConditionTreeEvaluator.evaluate(['not'], leaf_callback)).to eq(nil) end end @@ -166,7 +166,7 @@ allow(leaf_callback).to receive(:call).and_return(true, false) expect(Optimizely::ConditionTreeEvaluator.evaluate([@browser_condition, @device_condition], leaf_callback)).to be true - leaf_callback = ->(_condition) { return false } + leaf_callback = ->(_condition) { false } allow(leaf_callback).to receive(:call).and_return(false, true) expect(Optimizely::ConditionTreeEvaluator.evaluate([@browser_condition, @device_condition], leaf_callback)).to be true end diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index 3cf2bd31..e30d07e1 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -837,14 +837,14 @@ describe 'get_event_from_key' do it 'should log a message when provided event key is invalid' do config.get_event_from_key('invalid_key') - expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Event 'invalid_key' is not in datafile.") + expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Event key 'invalid_key' is not in datafile.") end end describe 'get_audience_from_id' do it 'should log a message when provided audience ID is invalid' do config.get_audience_from_id('invalid_id') - expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Audience 'invalid_id' is not in datafile.") + expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Audience id 'invalid_id' is not in datafile.") end end @@ -948,7 +948,7 @@ it 'should log a message when there is no experiment key map for the experiment' do config.get_whitelisted_variations('invalid_key') expect(spy_logger).to have_received(:log).with(Logger::ERROR, - "Experiment ID 'invalid_key' is not in datafile.") + "Experiment id 'invalid_key' is not in datafile.") end end diff --git a/spec/decision_service_spec.rb b/spec/decision_service_spec.rb index 7646c032..af22b18b 100644 --- a/spec/decision_service_spec.rb +++ b/spec/decision_service_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2017-2020, Optimizely and contributors +# Copyright 2017-2020, 2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ let(:spy_user_profile_service) { spy('user_profile_service') } let(:config) { Optimizely::DatafileProjectConfig.new(config_body_JSON, spy_logger, error_handler) } let(:decision_service) { Optimizely::DecisionService.new(spy_logger, spy_user_profile_service) } - let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) } + let(:project_instance) { Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler) } let(:user_context) { project_instance.create_user_context('some-user', {}) } after(:example) { project_instance.close } @@ -73,7 +73,8 @@ it 'should return the correct variation ID for a given user ID and key of a running experiment' do user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) expect(variation_received).to eq('111128') expect(reasons).to eq([ @@ -90,7 +91,8 @@ it 'should return nil when user ID is not bucketed' do allow(decision_service.bucketer).to receive(:bucket).and_return(nil) user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) expect(variation_received).to eq(nil) expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -189,7 +191,8 @@ it 'should return nil if the user does not meet the audience conditions for a given experiment' do user_attributes = {'browser_type' => 'chrome'} user_context = project_instance.create_user_context('test_user', user_attributes) - variation_received, reasons = decision_service.get_variation(config, '122227', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) + variation_received, reasons = decision_service.get_variation(config, '122227', user_context, user_profile_tracker) expect(variation_received).to eq(nil) expect(reasons).to eq([ "Starting to evaluate audience '11154' with conditions: [\"and\", [\"or\", [\"or\", {\"name\": \"browser_type\", \"type\": \"custom_attribute\", \"value\": \"firefox\"}]]].", @@ -240,7 +243,8 @@ it 'should bucket normally if user is whitelisted into a forced variation that is not in the datafile' do user_context = project_instance.create_user_context('forced_user_with_invalid_variation') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) expect(variation_received).to eq('111128') expect(reasons).to eq([ "User 'forced_user_with_invalid_variation' is whitelisted into variation 'invalid_variation', which is not in the datafile.", @@ -259,50 +263,14 @@ end describe 'when a UserProfile service is provided' do - it 'should look up the UserProfile, bucket normally, and save the result if no saved profile is found' do - expected_user_profile = { - user_id: 'test_user', - experiment_bucket_map: { - '111127' => { - variation_id: '111128' - } - } - } - expect(spy_user_profile_service).to receive(:lookup).once.and_return(nil) - - user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'control' of experiment '111127'." - ]) - - # bucketing should have occurred - expect(decision_service.bucketer).to have_received(:bucket).once - # bucketing decision should have been saved - expect(spy_user_profile_service).to have_received(:save).once.with(expected_user_profile) - expect(spy_logger).to have_received(:log).once - .with(Logger::INFO, "Saved variation ID 111128 of experiment ID 111127 for user 'test_user'.") - end - - it 'should look up the UserProfile, bucket normally (using Bucketing ID attribute), and save the result if no saved profile is found' do - expected_user_profile = { - user_id: 'test_user', - experiment_bucket_map: { - '111127' => { - variation_id: '111129' - } - } - } + it 'bucket normally (using Bucketing ID attribute)' do user_attributes = { 'browser_type' => 'firefox', Optimizely::Helpers::Constants::CONTROL_ATTRIBUTES['BUCKETING_ID'] => 'pid' } - expect(spy_user_profile_service).to receive(:lookup).once.and_return(nil) - user_context = project_instance.create_user_context('test_user', user_attributes) - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) expect(variation_received).to eq('111129') expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -311,13 +279,9 @@ # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once - # bucketing decision should have been saved - expect(spy_user_profile_service).to have_received(:save).once.with(expected_user_profile) - expect(spy_logger).to have_received(:log).once - .with(Logger::INFO, "Saved variation ID 111129 of experiment ID 111127 for user 'test_user'.") end - it 'should look up the user profile and skip normal bucketing if a profile with a saved decision is found' do + it 'skip normal bucketing if a profile with a saved decision is found' do saved_user_profile = { user_id: 'test_user', experiment_bucket_map: { @@ -330,7 +294,9 @@ .with('test_user').once.and_return(saved_user_profile) user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) + user_profile_tracker.load_user_profile + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) expect(variation_received).to eq('111129') expect(reasons).to eq([ "Returning previously activated variation ID 111129 of experiment 'test_experiment' for user 'test_user' from user profile." @@ -346,7 +312,7 @@ expect(spy_user_profile_service).not_to have_received(:save) end - it 'should look up the user profile and bucket normally if a profile without a saved decision is found' do + it 'bucket normally if a profile without a saved decision is found' do saved_user_profile = { user_id: 'test_user', experiment_bucket_map: { @@ -360,7 +326,9 @@ .once.with('test_user').and_return(saved_user_profile) user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) + user_profile_tracker.load_user_profile + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) expect(variation_received).to eq('111128') expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -369,20 +337,6 @@ # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once - - # user profile should have been updated with bucketing decision - expected_user_profile = { - user_id: 'test_user', - experiment_bucket_map: { - '111127' => { - variation_id: '111128' - }, - '122227' => { - variation_id: '122228' - } - } - } - expect(spy_user_profile_service).to have_received(:save).once.with(expected_user_profile) end it 'should bucket normally if the user profile contains a variation ID not in the datafile' do @@ -399,7 +353,9 @@ .once.with('test_user').and_return(saved_user_profile) user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) + user_profile_tracker.load_user_profile + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) expect(variation_received).to eq('111128') expect(reasons).to eq([ "User 'test_user' was previously bucketed into variation ID '111111' for experiment '111127', but no matching variation was found. Re-bucketing user.", @@ -409,27 +365,18 @@ # bucketing should have occurred expect(decision_service.bucketer).to have_received(:bucket).once - - # user profile should have been updated with bucketing decision - expected_user_profile = { - user_id: 'test_user', - experiment_bucket_map: { - '111127' => { - variation_id: '111128' - } - } - } - expect(spy_user_profile_service).to have_received(:save).with(expected_user_profile) end - it 'should bucket normally if the user profile service throws an error during lookup' do + it 'should bucket normally if the user profile tracker throws an error during lookup' do expect(spy_user_profile_service).to receive(:lookup).once.with('test_user').and_throw(:LookupError) user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) + user_profile_tracker.load_user_profile + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker) + user_profile_tracker.save_user_profile expect(variation_received).to eq('111128') expect(reasons).to eq([ - "Error while looking up user profile for user ID 'test_user': uncaught throw :LookupError.", "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", "User 'test_user' is in variation 'control' of experiment '111127'." ]) @@ -440,46 +387,15 @@ expect(decision_service.bucketer).to have_received(:bucket).once end - it 'should log an error if the user profile service throws an error during save' do - expect(spy_user_profile_service).to receive(:save).once.and_throw(:SaveError) - - user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'control' of experiment '111127'." - ]) - - expect(spy_logger).to have_received(:log).once - .with(Logger::ERROR, "Error while saving user profile for user ID 'test_user': uncaught throw :SaveError.") - end - describe 'IGNORE_USER_PROFILE_SERVICE decide option' do it 'should ignore user profile service if this option is set' do allow(spy_user_profile_service).to receive(:lookup) .with('test_user').once.and_return(nil) user_context = project_instance.create_user_context('test_user', nil) - variation_received, reasons = decision_service.get_variation(config, '111127', user_context, [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE]) - expect(variation_received).to eq('111128') - expect(reasons).to eq([ - "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", - "User 'test_user' is in variation 'control' of experiment '111127'." - ]) - - expect(decision_service.bucketer).to have_received(:bucket) - expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) - expect(spy_user_profile_service).not_to have_received(:lookup) - expect(spy_user_profile_service).not_to have_received(:save) - end - - it 'should not ignore user profile service if this option is not set' do - allow(spy_user_profile_service).to receive(:lookup) - .with('test_user').once.and_return(nil) - - user_context = project_instance.create_user_context('test_user') - variation_received, reasons = decision_service.get_variation(config, '111127', user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id, spy_user_profile_service, spy_logger) + user_profile_tracker.load_user_profile + variation_received, reasons = decision_service.get_variation(config, '111127', user_context, user_profile_tracker, [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE]) expect(variation_received).to eq('111128') expect(reasons).to eq([ "Audiences for experiment 'test_experiment' collectively evaluated to TRUE.", @@ -488,8 +404,6 @@ expect(decision_service.bucketer).to have_received(:bucket) expect(Optimizely::Audience).to have_received(:user_meets_audience_conditions?) - expect(spy_user_profile_service).to have_received(:lookup) - expect(spy_user_profile_service).to have_received(:save) end end end @@ -497,13 +411,13 @@ describe '#get_variation_for_feature_experiment' do config_body_json = OptimizelySpec::VALID_CONFIG_BODY_JSON - project_instance = Optimizely::Project.new(config_body_json, nil, nil, nil) + project_instance = Optimizely::Project.new(datafile: config_body_json) user_context = project_instance.create_user_context('user_1', {}) - + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) describe 'when the feature flag\'s experiment ids array is empty' do it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['empty_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) expect(variation_received).to eq(nil) expect(reasons).to eq(["The feature flag 'empty_feature' is not used in any experiments."]) @@ -517,7 +431,8 @@ feature_flag = config.feature_flag_key_map['boolean_feature'].dup # any string that is not an experiment id in the data file feature_flag['experimentIds'] = ['1333333337'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) expect(variation_received).to eq(nil) expect(reasons).to eq(["Feature flag experiment with ID '1333333337' is not in the datafile."]) expect(spy_logger).to have_received(:log).once @@ -526,19 +441,19 @@ end describe 'when the feature flag is associated with a non-mutex experiment' do + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) describe 'and the user is not bucketed into the feature flag\'s experiments' do before(:each) do multivariate_experiment = config.experiment_key_map['test_experiment_multivariate'] - # make sure the user is not bucketed into the feature experiment allow(decision_service).to receive(:get_variation) - .with(config, multivariate_experiment['id'], user_context, []) + .with(config, multivariate_experiment['id'], user_context, user_profile_tracker, []) .and_return([nil, nil]) end it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['multi_variate_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, []) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker, []) expect(variation_received).to eq(nil) expect(reasons).to eq(["The user 'user_1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'."]) @@ -560,8 +475,8 @@ config.variation_id_map['test_experiment_multivariate']['122231'], Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) expect(variation_received).to eq(expected_decision) expect(reasons).to eq([]) end @@ -586,27 +501,29 @@ it 'should return the variation the user is bucketed into' do feature_flag = config.feature_flag_key_map['mutex_group_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) expect(variation_received).to eq(expected_decision) expect(reasons).to eq([]) end end describe 'and the user is not bucketed into any of the mutex experiments' do + user_profile_tracker = Optimizely::UserProfileTracker.new(user_context.user_id) before(:each) do mutex_exp = config.experiment_key_map['group1_exp1'] mutex_exp2 = config.experiment_key_map['group1_exp2'] allow(decision_service).to receive(:get_variation) - .with(config, mutex_exp['id'], user_context, []) + .with(config, mutex_exp['id'], user_context, user_profile_tracker, []) .and_return([nil, nil]) allow(decision_service).to receive(:get_variation) - .with(config, mutex_exp2['id'], user_context, []) + .with(config, mutex_exp2['id'], user_context, user_profile_tracker, []) .and_return([nil, nil]) end it 'should return nil and log a message' do feature_flag = config.feature_flag_key_map['mutex_group_feature'] - variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context) + variation_received, reasons = decision_service.get_variation_for_feature_experiment(config, feature_flag, user_context, user_profile_tracker) expect(variation_received).to eq(nil) expect(reasons).to eq(["The user 'user_1' is not bucketed into any of the experiments on the feature 'mutex_group_feature'."]) @@ -619,7 +536,7 @@ describe '#get_variation_for_feature_rollout' do config_body_json = OptimizelySpec::VALID_CONFIG_BODY_JSON - project_instance = Optimizely::Project.new(config_body_json, nil, nil, nil) + project_instance = Optimizely::Project.new(datafile: config_body_json) user_context = project_instance.create_user_context('user_1', {}) user_id = 'user_1' @@ -816,7 +733,7 @@ describe '#get_variation_for_feature' do config_body_json = OptimizelySpec::VALID_CONFIG_BODY_JSON - project_instance = Optimizely::Project.new(config_body_json, nil, nil, nil) + project_instance = Optimizely::Project.new(datafile: config_body_json) user_context = project_instance.create_user_context('user_1', {}) describe 'when the user is bucketed into the feature experiment' do diff --git a/spec/notification_center_registry_spec.rb b/spec/notification_center_registry_spec.rb index ab783ef5..2a4521c7 100644 --- a/spec/notification_center_registry_spec.rb +++ b/spec/notification_center_registry_spec.rb @@ -42,7 +42,7 @@ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_JSON) - project = Optimizely::Project.new(nil, nil, spy_logger, nil, false, nil, sdk_key) + project = Optimizely::Project.new(logger: spy_logger, sdk_key: sdk_key) notification_center = Optimizely::NotificationCenterRegistry.get_notification_center(sdk_key, spy_logger) expect(notification_center).to be_a Optimizely::NotificationCenter @@ -60,7 +60,7 @@ .to_return(status: 200, body: config_body_JSON) notification_center = Optimizely::NotificationCenterRegistry.get_notification_center(sdk_key, spy_logger) - project = Optimizely::Project.new(nil, nil, spy_logger, nil, false, nil, sdk_key) + project = Optimizely::Project.new(logger: spy_logger, sdk_key: sdk_key) expect(notification_center).to eq(Optimizely::NotificationCenterRegistry.get_notification_center(sdk_key, spy_logger)) expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything) @@ -78,7 +78,7 @@ notification_center = Optimizely::NotificationCenterRegistry.get_notification_center(sdk_key, spy_logger) expect(notification_center).to receive(:send_notifications).once - project = Optimizely::Project.new(nil, nil, spy_logger, nil, false, nil, sdk_key) + project = Optimizely::Project.new(logger: spy_logger, sdk_key: sdk_key) project.config_manager.config Optimizely::NotificationCenterRegistry.remove_notification_center(sdk_key) diff --git a/spec/optimizely_config_spec.rb b/spec/optimizely_config_spec.rb index 8d364e1d..4164d3ca 100644 --- a/spec/optimizely_config_spec.rb +++ b/spec/optimizely_config_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2019-2021, Optimizely and contributors +# Copyright 2019-2021, 2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ require 'spec_helper' describe Optimizely::OptimizelyConfig do + let(:config_body) { OptimizelySpec::VALID_CONFIG_BODY } let(:config_body_JSON) { OptimizelySpec::VALID_CONFIG_BODY_JSON } let(:similar_exp_keys_JSON) { OptimizelySpec::SIMILAR_EXP_KEYS_JSON } let(:typed_audiences_JSON) { OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES_JSON } @@ -26,16 +27,16 @@ let(:error_handler) { Optimizely::NoOpErrorHandler.new } let(:spy_logger) { spy('logger') } let(:project_config) { Optimizely::DatafileProjectConfig.new(config_body_JSON, spy_logger, error_handler) } - let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) } + let(:project_instance) { Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler) } let(:optimizely_config) { project_instance.get_optimizely_config } let(:project_config_sim_keys) { Optimizely::DatafileProjectConfig.new(similar_exp_keys_JSON, spy_logger, error_handler) } - let(:project_instance_sim_keys) { Optimizely::Project.new(similar_exp_keys_JSON, nil, spy_logger, error_handler) } + let(:project_instance_sim_keys) { Optimizely::Project.new(datafile: similar_exp_keys_JSON, logger: spy_logger, error_handler: error_handler) } let(:optimizely_config_sim_keys) { project_instance_sim_keys.get_optimizely_config } let(:project_config_typed_audiences) { Optimizely::DatafileProjectConfig.new(typed_audiences_JSON, spy_logger, error_handler) } - let(:project_instance_typed_audiences) { Optimizely::Project.new(typed_audiences_JSON, nil, spy_logger, error_handler) } + let(:project_instance_typed_audiences) { Optimizely::Project.new(datafile: typed_audiences_JSON, logger: spy_logger, error_handler: error_handler) } let(:optimizely_config_typed_audiences) { project_instance_typed_audiences.get_optimizely_config } let(:project_config_similar_rule_keys) { Optimizely::DatafileProjectConfig.new(similar_rule_key_JSON, spy_logger, error_handler) } - let(:project_instance_similar_rule_keys) { Optimizely::Project.new(similar_rule_key_JSON, nil, spy_logger, error_handler) } + let(:project_instance_similar_rule_keys) { Optimizely::Project.new(datafile: similar_rule_key_JSON, logger: spy_logger, error_handler: error_handler) } let(:optimizely_config_similar_rule_keys) { project_instance_similar_rule_keys.get_optimizely_config } after(:example) do project_instance.close @@ -768,7 +769,7 @@ '', '"exactString" OR "999999999"' ] - optimizely_config = Optimizely::OptimizelyConfig.new(project_instance_typed_audiences.send(:project_config)) + optimizely_config = Optimizely::OptimizelyConfig.new(project_instance_typed_audiences.send(:project_config), spy_logger) audiences_map = optimizely_config.send(:audiences_map) audience_conditions.each_with_index do |audience_condition, index| result = optimizely_config.send(:replace_ids_with_names, audience_condition, audiences_map) @@ -796,4 +797,64 @@ expect(optimizely_config_similar_rule_keys['sdkKey']).to eq('') expect(optimizely_config_similar_rule_keys['environmentKey']).to eq('') end + + it 'should use the newest of duplicate experiment keys' do + duplicate_experiment_key = 'test_experiment' + new_experiment = { + 'key': duplicate_experiment_key, + 'status': 'Running', + 'layerId': '8', + "audienceConditions": %w[ + or + 11160 + ], + 'audienceIds': ['11160'], + 'id': '111137', + 'forcedVariations': {}, + 'trafficAllocation': [ + {'entityId': '222242', 'endOfRange': 8000}, + {'entityId': '', 'endOfRange': 10_000} + ], + 'variations': [ + { + 'id': '222242', + 'key': 'control', + 'variables': [] + } + ] + } + + new_feature = { + 'id': '91117', + 'key': 'new_feature', + 'experimentIds': ['111137'], + 'rolloutId': '', + 'variables': [ + {'id': '127', 'key': 'is_working', 'defaultValue': 'true', 'type': 'boolean'}, + {'id': '128', 'key': 'environment', 'defaultValue': 'devel', 'type': 'string'}, + {'id': '129', 'key': 'cost', 'defaultValue': '10.99', 'type': 'double'}, + {'id': '130', 'key': 'count', 'defaultValue': '999', 'type': 'integer'}, + {'id': '131', 'key': 'variable_without_usage', 'defaultValue': '45', 'type': 'integer'}, + {'id': '132', 'key': 'object', 'defaultValue': '{"test": 12}', 'type': 'string', 'subType': 'json'}, + {'id': '133', 'key': 'true_object', 'defaultValue': '{"true_test": 23.54}', 'type': 'json'} + ] + } + + config = OptimizelySpec.deep_clone(config_body) + + config['experiments'].push(new_experiment) + config['featureFlags'].push(new_feature) + project_config = Optimizely::DatafileProjectConfig.new(JSON.dump(config), spy_logger, error_handler) + + opti_config = Optimizely::OptimizelyConfig.new(project_config, spy_logger) + + key_map = opti_config.config['experimentsMap'] + id_map = opti_config.send(:experiments_id_map) + + expected_warning_message = "Duplicate experiment keys found in datafile: #{duplicate_experiment_key}" + expect(spy_logger).to have_received(:log).once.with(Logger::WARN, expected_warning_message) + + expect(key_map[duplicate_experiment_key]['id']).to eq(new_experiment[:id]) + expect(id_map.values.count { |exp| exp['key'] == duplicate_experiment_key }).to eq(2) + end end diff --git a/spec/optimizely_user_context_spec.rb b/spec/optimizely_user_context_spec.rb index 6a99c57b..515068c0 100644 --- a/spec/optimizely_user_context_spec.rb +++ b/spec/optimizely_user_context_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2020, 2022, Optimizely and contributors +# Copyright 2020, 2022-2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -27,9 +27,9 @@ let(:integration_JSON) { OptimizelySpec::CONFIG_DICT_WITH_INTEGRATIONS_JSON } let(:error_handler) { Optimizely::RaiseErrorHandler.new } let(:spy_logger) { spy('logger') } - let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) } - let(:forced_decision_project_instance) { Optimizely::Project.new(forced_decision_JSON, nil, spy_logger, error_handler, false, nil, nil, nil, nil, nil, [], {batch_size: 1}) } - let(:integration_project_instance) { Optimizely::Project.new(integration_JSON, nil, spy_logger, error_handler) } + let(:project_instance) { Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler) } + let(:forced_decision_project_instance) { Optimizely::Project.new(datafile: forced_decision_JSON, logger: spy_logger, error_handler: error_handler, event_processor_options: {batch_size: 1}) } + let(:integration_project_instance) { Optimizely::Project.new(datafile: integration_JSON, logger: spy_logger, error_handler: error_handler) } let(:impression_log_url) { 'https://logx.optimizely.com/v1/events' } let(:good_response_data) do { @@ -251,7 +251,9 @@ variation_key: '3324490562', rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: '3324490562' ) user_context_obj = forced_decision_project_instance.create_user_context(user_id) context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(feature_key, nil) @@ -347,7 +349,9 @@ variation_key: 'b', rule_key: 'exp_with_audience', reasons: ['Variation (b) is mapped to flag (feature_1), rule (exp_with_audience) and user (tester) in the forced decision map.'], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: '10390977673', + variation_id: '10416523121' ) user_context_obj = Optimizely::OptimizelyUserContext.new(forced_decision_project_instance, user_id, original_attributes) context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(feature_key, 'exp_with_audience') @@ -464,7 +468,9 @@ variation_key: '3324490562', rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: '3324490562' ) user_context_obj = forced_decision_project_instance.create_user_context(user_id) context_with_flag = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(feature_key, nil) diff --git a/spec/project_spec.rb b/spec/project_spec.rb index d00f93c1..f857a5ce 100644 --- a/spec/project_spec.rb +++ b/spec/project_spec.rb @@ -48,7 +48,7 @@ let(:version) { Optimizely::VERSION } let(:impression_log_url) { 'https://logx.optimizely.com/v1/events' } let(:conversion_log_url) { 'https://logx.optimizely.com/v1/events' } - let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler, false, nil, nil, nil, nil, nil, [], {batch_size: 1}) } + let(:project_instance) { Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, event_processor_options: {batch_size: 1}) } let(:project_config) { project_instance.config_manager.config } let(:time_now) { Time.now } let(:post_headers) { {'Content-Type' => 'application/json'} } @@ -71,7 +71,7 @@ def log(_level, log_message) end logger = CustomLogger.new - instance_with_logger = Optimizely::Project.new(config_body_JSON, nil, logger) + instance_with_logger = Optimizely::Project.new(datafile: config_body_JSON, logger: logger) expect(instance_with_logger.logger.log(Logger::INFO, 'test_message')).to eq('test_message') instance_with_logger.close end @@ -84,19 +84,19 @@ def handle_error(error) end error_handler = CustomErrorHandler.new - instance_with_error_handler = Optimizely::Project.new(config_body_JSON, nil, nil, error_handler) + instance_with_error_handler = Optimizely::Project.new(datafile: config_body_JSON, error_handler: error_handler) expect(instance_with_error_handler.error_handler.handle_error('test_message')).to eq('test_message') instance_with_error_handler.close end it 'should log an error when datafile is null' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') - Optimizely::Project.new(nil, nil, spy_logger).close + Optimizely::Project.new(logger: spy_logger).close end it 'should log an error when datafile is empty' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') - Optimizely::Project.new('', nil, spy_logger).close + Optimizely::Project.new(datafile: '', logger: spy_logger).close end it 'should log an error when given a datafile that does not conform to the schema' do @@ -104,7 +104,7 @@ def handle_error(error) allow(spy_logger).to receive(:log).with(Logger::DEBUG, anything) expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'SDK key not provided/cannot be found in the datafile. ODP may not work properly without it.') - Optimizely::Project.new('{"foo": "bar"}', nil, spy_logger).close + Optimizely::Project.new(datafile: '{"foo": "bar"}', logger: spy_logger).close end it 'should log an error when given an invalid logger' do @@ -114,7 +114,7 @@ def handle_error(error) expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'Provided logger is in an invalid format.') class InvalidLogger; end # rubocop:disable Lint/ConstantDefinitionInBlock - Optimizely::Project.new(config_body_JSON, nil, InvalidLogger.new).close + Optimizely::Project.new(datafile: config_body_JSON, logger: InvalidLogger.new).close end it 'should log an error when given an invalid event_dispatcher' do @@ -123,7 +123,7 @@ class InvalidLogger; end # rubocop:disable Lint/ConstantDefinitionInBlock expect_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided event_dispatcher is in an invalid format.') class InvalidEventDispatcher; end # rubocop:disable Lint/ConstantDefinitionInBlock - Optimizely::Project.new(config_body_JSON, InvalidEventDispatcher.new).close + Optimizely::Project.new(datafile: config_body_JSON, event_dispatcher: InvalidEventDispatcher.new).close end it 'should log an error when given an invalid error_handler' do @@ -132,14 +132,14 @@ class InvalidEventDispatcher; end # rubocop:disable Lint/ConstantDefinitionInBlo expect_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided error_handler is in an invalid format.') class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock - Optimizely::Project.new(config_body_JSON, nil, nil, InvalidErrorHandler.new).close + Optimizely::Project.new(datafile: config_body_JSON, error_handler: InvalidErrorHandler.new).close end it 'should not validate the JSON schema of the datafile when skip_json_validation is true' do project_instance.close expect(Optimizely::Helpers::Validator).not_to receive(:datafile_valid?) - Optimizely::Project.new(config_body_JSON, nil, nil, nil, true).close + Optimizely::Project.new(datafile: config_body_JSON, skip_json_validation: true).close end it 'should be invalid when datafile contains integrations missing key' do @@ -152,7 +152,7 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock config['integrations'][0].delete('key') integrations_json = JSON.dump(config) - Optimizely::Project.new(integrations_json, nil, spy_logger) + Optimizely::Project.new(datafile: integrations_json, logger: spy_logger) end it 'should be valid when datafile contains integrations with only key' do @@ -161,7 +161,7 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock config['integrations'].push('key' => '123') integrations_json = JSON.dump(config) - project_instance = Optimizely::Project.new(integrations_json) + project_instance = Optimizely::Project.new(datafile: integrations_json) expect(project_instance.is_valid).to be true end @@ -171,7 +171,7 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock config['integrations'].push('key' => 'future', 'any-key-1' => 1, 'any-key-2' => 'any-value-2') integrations_json = JSON.dump(config) - project_instance = Optimizely::Project.new(integrations_json) + project_instance = Optimizely::Project.new(datafile: integrations_json) expect(project_instance.is_valid).to be true end @@ -179,20 +179,20 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect_any_instance_of(Optimizely::RaiseErrorHandler).to receive(:handle_error).once.with(Optimizely::InvalidInputError) - Optimizely::Project.new('this is not JSON', nil, spy_logger, Optimizely::RaiseErrorHandler.new, true) + Optimizely::Project.new(datafile: 'this is not JSON', logger: spy_logger, error_handler: Optimizely::RaiseErrorHandler.new, skip_json_validation: true) end it 'should log an error when provided an invalid JSON datafile and skip_json_validation is true' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') - Optimizely::Project.new('{"version": "2", "foo": "bar"}', nil, spy_logger, nil, true) + Optimizely::Project.new(datafile: '{"version": "2", "foo": "bar"}', logger: spy_logger, skip_json_validation: true) end it 'should log and raise an error when provided a datafile of unsupported version' do config_body_invalid_json = JSON.parse(config_body_invalid_JSON) expect(spy_logger).to receive(:log).once.with(Logger::ERROR, "This version of the Ruby SDK does not support the given datafile version: #{config_body_invalid_json['version']}.") - expect { Optimizely::Project.new(config_body_invalid_JSON, nil, spy_logger, Optimizely::RaiseErrorHandler.new, true) }.to raise_error(Optimizely::InvalidDatafileVersionError, 'This version of the Ruby SDK does not support the given datafile version: 5.') + expect { Optimizely::Project.new(datafile: config_body_invalid_JSON, logger: spy_logger, error_handler: Optimizely::RaiseErrorHandler.new, skip_json_validation: true) }.to raise_error(Optimizely::InvalidDatafileVersionError, 'This version of the Ruby SDK does not support the given datafile version: 5.') end end @@ -225,7 +225,7 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock end it 'should send identify event when called with odp enabled' do - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) expect(project.odp_manager).to receive(:identify_user).with({user_id: 'tester'}) project.create_user_context('tester') @@ -359,7 +359,7 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock describe '.typed audiences' do before(:example) do - @project_typed_audience_instance = Optimizely::Project.new(JSON.dump(OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES), nil, spy_logger, error_handler, false, nil, nil, nil, nil, nil, [], {batch_size: 1}) + @project_typed_audience_instance = Optimizely::Project.new(datafile: JSON.dump(OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES), logger: spy_logger, error_handler: error_handler, event_processor_options: {batch_size: 1}) @project_config = @project_typed_audience_instance.config_manager.config @expected_activate_params = { account_id: '4879520872', @@ -900,7 +900,7 @@ def callback(_args); end end it 'should log an error when called with an invalid Project object' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) invalid_project.activate('test_exp', 'test_user') expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Optimizely instance is not valid. Failing 'activate'.") @@ -976,8 +976,8 @@ def callback(_args); end ) custom_project_instance = Optimizely::Project.new( - nil, nil, spy_logger, error_handler, - false, nil, nil, http_project_config_manager, notification_center + logger: spy_logger, error_handler: error_handler, + config_manager: http_project_config_manager, notification_center: notification_center ) sleep 0.1 until http_project_config_manager.ready? @@ -1003,8 +1003,8 @@ def callback(_args); end ) custom_project_instance = Optimizely::Project.new( - nil, nil, spy_logger, error_handler, - false, nil, nil, http_project_config_manager, notification_center + logger: spy_logger, error_handler: error_handler, + config_manager: http_project_config_manager, notification_center: notification_center ) sleep 0.1 until http_project_config_manager.ready? @@ -1037,8 +1037,8 @@ def callback(_args); end expect(notification_center).to receive(:send_notifications).ordered custom_project_instance = Optimizely::Project.new( - nil, nil, spy_logger, error_handler, - false, nil, sdk_key, nil, notification_center + logger: spy_logger, error_handler: error_handler, + sdk_key: sdk_key, notification_center: notification_center ) sleep 0.1 until custom_project_instance.config_manager.ready? @@ -1132,7 +1132,7 @@ def callback(_args); end end it 'should properly track an event with tags even when the project does not have a custom logger' do - custom_project_instance = Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler, false, nil, nil, nil, nil, nil, [], {batch_size: 1}) + custom_project_instance = Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, event_processor_options: {batch_size: 1}) params = @expected_track_event_params params[:visitors][0][:snapshots][0][:events][0][:tags] = {revenue: 42} @@ -1217,7 +1217,7 @@ def callback(_args); end describe '.typed audiences' do before(:example) do - @project_typed_audience_instance = Optimizely::Project.new(JSON.dump(OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES), nil, spy_logger, error_handler, false, nil, nil, nil, nil, nil, [], {batch_size: 1}) + @project_typed_audience_instance = Optimizely::Project.new(datafile: JSON.dump(OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES), logger: spy_logger, error_handler: error_handler, event_processor_options: {batch_size: 1}) @expected_event_params = { account_id: '4879520872', project_id: '11624721371', @@ -1424,7 +1424,7 @@ def callback(_args); end end it 'should log an error when called with an invalid Project object' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) invalid_project.track('test_event', 'test_user') expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Optimizely instance is not valid. Failing 'track'.") @@ -1532,7 +1532,7 @@ def callback(_args); end end it 'should log an error when called with an invalid Project object' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) invalid_project.get_variation('test_exp', 'test_user') expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Optimizely instance is not valid. Failing 'get_variation'.") @@ -1620,7 +1620,7 @@ def callback(_args); end end it 'should return false when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.is_feature_enabled('totally_invalid_feature_key', 'test_user')).to be false expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "Optimizely instance is not valid. Failing 'is_feature_enabled'.") @@ -1758,7 +1758,7 @@ def callback(_args); end describe '.typed audiences' do before(:example) do - @project_typed_audience_instance = Optimizely::Project.new(JSON.dump(OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES), nil, spy_logger, error_handler) + @project_typed_audience_instance = Optimizely::Project.new(datafile: JSON.dump(OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES), logger: spy_logger, error_handler: error_handler) stub_request(:post, impression_log_url) end after(:example) do @@ -2023,7 +2023,7 @@ def callback(_args); end describe '#get_enabled_features' do it 'should return empty when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.get_enabled_features('test_user')).to be_empty expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "Optimizely instance is not valid. Failing 'get_enabled_features'.") @@ -2079,7 +2079,7 @@ def callback(_args); end it 'should return only enabled feature flags keys' do # Sets all feature-flags keys with randomly assigned status features_keys = project_config.feature_flags.map do |item| - {key: (item['key']).to_s, value: [true, false].sample} # '[true, false].sample' generates random boolean + {key: item['key'].to_s, value: [true, false].sample} # '[true, false].sample' generates random boolean end enabled_features = features_keys.map { |x| x[:key] if x[:value] == true }.compact @@ -2249,7 +2249,7 @@ def callback(_args); end user_attributes = {} it 'should return nil when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.get_feature_variable_string('string_single_variable_feature', 'string_variable', user_id, user_attributes)) .to eq(nil) expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') @@ -2399,7 +2399,7 @@ def callback(_args); end user_attributes = {} it 'should return nil when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.get_feature_variable_json('json_single_variable_feature', 'json_variable', user_id, user_attributes)) .to eq(nil) expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') @@ -2582,7 +2582,7 @@ def callback(_args); end user_attributes = {} it 'should return nil when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.get_feature_variable_boolean('boolean_single_variable_feature', 'boolean_variable', user_id, user_attributes)) .to eq(nil) expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') @@ -2626,7 +2626,7 @@ def callback(_args); end user_attributes = {} it 'should return nil when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.get_feature_variable_double('double_single_variable_feature', 'double_variable', user_id, user_attributes)) .to eq(nil) expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') @@ -2672,7 +2672,7 @@ def callback(_args); end user_attributes = {} it 'should return nil when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.get_feature_variable_integer('integer_single_variable_feature', 'integer_variable', user_id, user_attributes)) .to eq(nil) expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') @@ -2718,7 +2718,7 @@ def callback(_args); end user_attributes = {} it 'should return nil when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.get_all_feature_variables('all_variables_feature', user_id, user_attributes)) .to eq(nil) expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') @@ -2951,7 +2951,7 @@ def callback(_args); end user_attributes = {} it 'should return nil when called with invalid project config' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.get_feature_variable('string_single_variable_feature', 'string_variable', user_id, user_attributes)) .to eq(nil) expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.') @@ -3161,7 +3161,7 @@ def callback(_args); end describe '.typed audiences' do before(:example) do - @project_typed_audience_instance = Optimizely::Project.new(JSON.dump(OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES), nil, spy_logger, error_handler) + @project_typed_audience_instance = Optimizely::Project.new(datafile: JSON.dump(OptimizelySpec::CONFIG_DICT_WITH_TYPED_AUDIENCES), logger: spy_logger, error_handler: error_handler) end after(:example) do @project_typed_audience_instance.close @@ -3466,7 +3466,7 @@ def callback(_args); end valid_variation = {id: '111128', key: 'control'} it 'should log an error when called with an invalid Project object' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) invalid_project.set_forced_variation(valid_experiment[:key], user_id, valid_variation[:key]) expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Optimizely instance is not valid. Failing 'set_forced_variation'.") @@ -3523,7 +3523,7 @@ def callback(_args); end valid_experiment = {id: '111127', key: 'test_experiment'} it 'should log an error when called with an invalid Project object' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) invalid_project.get_forced_variation(valid_experiment[:key], user_id) expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'Provided datafile is in an invalid format.') expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Optimizely instance is not valid. Failing 'get_forced_variation'.") @@ -3569,7 +3569,7 @@ def callback(_args); end describe '#is_valid' do it 'should return false when called with an invalid datafile' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) expect(invalid_project.is_valid).to be false invalid_project.close end @@ -3595,9 +3595,9 @@ def callback(_args); end event_processor = Optimizely::BatchEventProcessor.new(event_dispatcher: Optimizely::EventDispatcher.new) - Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler).close + Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler).close - project_instance = Optimizely::Project.new(nil, nil, nil, nil, true, nil, nil, config_manager, nil, event_processor) + project_instance = Optimizely::Project.new(skip_json_validation: true, config_manager: config_manager, event_processor: event_processor) expect(config_manager.stopped).to be false expect(event_processor.started).to be false @@ -3617,8 +3617,8 @@ def callback(_args); end ) project_instance = Optimizely::Project.new( - nil, nil, spy_logger, error_handler, - false, nil, nil, http_project_config_manager + logger: spy_logger, error_handler: error_handler, + config_manager: http_project_config_manager ) project_instance.close @@ -3631,8 +3631,8 @@ def callback(_args); end ) project_instance = Optimizely::Project.new( - config_body_JSON, nil, spy_logger, error_handler, - false, nil, nil, http_project_config_manager + datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, + config_manager: http_project_config_manager ) sleep 0.1 until http_project_config_manager.ready? @@ -3652,8 +3652,8 @@ def callback(_args); end ) project_instance = Optimizely::Project.new( - nil, nil, spy_logger, error_handler, - false, nil, nil, static_project_config_manager + logger: spy_logger, error_handler: error_handler, + config_manager: static_project_config_manager ) project_instance.close @@ -3666,8 +3666,8 @@ def callback(_args); end ) project_instance = Optimizely::Project.new( - config_body_JSON, nil, spy_logger, error_handler, - false, nil, nil, static_project_config_manager + datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, + config_manager: static_project_config_manager ) project_instance.close @@ -3696,7 +3696,7 @@ def callback(_args); end describe '#decide' do describe 'should return empty decision object with correct reason when sdk is not ready' do it 'when sdk is not ready' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) user_context = project_instance.create_user_context('user1') decision = invalid_project.decide(user_context, 'dummy_flag') expect(decision.as_json).to eq( @@ -3758,7 +3758,9 @@ def callback(_args); end variation_key: 'Fred', rule_key: 'test_experiment_multivariate', reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: experiment_to_return['id'], + variation_id: variation_to_return['id'] ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) decision_to_return = Optimizely::DecisionService::Decision.new( @@ -3766,7 +3768,9 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_list_to_be_returned = [] + decision_list_to_be_returned << [decision_to_return, []] + allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_be_returned) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature') expect(decision.as_json).to include( @@ -3799,7 +3803,9 @@ def callback(_args); end variation_key: 'Fred', rule_key: 'test_experiment_multivariate', reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: experiment_to_return['id'], + variation_id: variation_to_return['id'] ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) decision_to_return = Optimizely::DecisionService::Decision.new( @@ -3807,7 +3813,9 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_list_to_be_returned = [] + decision_list_to_be_returned << [decision_to_return, []] + allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_be_returned) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature') @@ -3879,7 +3887,9 @@ def callback(_args); end variation_key: 'Fred', rule_key: 'test_experiment_multivariate', reasons: [], - decision_event_dispatched: false + decision_event_dispatched: false, + experiment_id: experiment_to_return['id'], + variation_id: variation_to_return['id'] ) allow(project_config).to receive(:send_flag_decisions).and_return(false) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) @@ -3888,7 +3898,8 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['ROLLOUT'] ) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + decision_list_to_return = [[decision_to_return, []]] + allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature') expect(decision.as_json).to include( @@ -3916,7 +3927,9 @@ def callback(_args); end variation_key: nil, rule_key: nil, reasons: [], - decision_event_dispatched: false + decision_event_dispatched: false, + experiment_id: nil, + variation_id: nil ) allow(project_config).to receive(:send_flag_decisions).and_return(false) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) @@ -3953,7 +3966,9 @@ def callback(_args); end variation_key: nil, rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) decision_to_return = nil @@ -4055,8 +4070,9 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) + decision_list_to_be_returned = [[decision_to_return, []]] allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_be_returned) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature', [Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES]) expect(decision.as_json).to include( @@ -4078,8 +4094,9 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) + decision_list_to_return = [[decision_to_return, []]] allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature') expect(decision.as_json).to include( @@ -4096,8 +4113,6 @@ def callback(_args); end describe 'INCLUDE_REASONS' do it 'should include reasons when the option is set' do - expect(project_instance.notification_center).to receive(:send_notifications) - .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) expect(project_instance.notification_center).to receive(:send_notifications) .once.with( Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION], @@ -4117,8 +4132,12 @@ def callback(_args); end "The user 'user1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.", "Feature flag 'multi_variate_feature' is not used in a rollout." ], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) + expect(project_instance.notification_center).to receive(:send_notifications) + .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) user_context = project_instance.create_user_context('user1') decision = project_instance.decide(user_context, 'multi_variate_feature', [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS]) @@ -4155,7 +4174,9 @@ def callback(_args); end variation_key: nil, rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) user_context = project_instance.create_user_context('user1') @@ -4180,23 +4201,23 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) + decision_list_to_return = [[decision_to_return, []]] allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = project_instance.create_user_context('user1') - expect(project_instance.decision_service).to receive(:get_variation_for_feature) + expect(project_instance.decision_service).to receive(:get_variations_for_feature_list) .with(anything, anything, anything, []).once project_instance.decide(user_context, 'multi_variate_feature') - expect(project_instance.decision_service).to receive(:get_variation_for_feature) + expect(project_instance.decision_service).to receive(:get_variations_for_feature_list) .with(anything, anything, anything, [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT]).once project_instance.decide(user_context, 'multi_variate_feature', [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT]) - expect(project_instance.decision_service).to receive(:get_variation_for_feature) + expect(project_instance.decision_service).to receive(:get_variations_for_feature_list) .with(anything, anything, anything, [ Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT, Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES, - Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY, Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE, Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS, Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES @@ -4216,7 +4237,7 @@ def callback(_args); end describe '#decide_all' do it 'should get empty object when sdk is not ready' do - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) user_context = project_instance.create_user_context('user1') decisions = invalid_project.decide_all(user_context) expect(decisions).to eq({}) @@ -4283,7 +4304,7 @@ def callback(_args); end boolean_feature empty_feature ] - invalid_project = Optimizely::Project.new('invalid', nil, spy_logger) + invalid_project = Optimizely::Project.new(datafile: 'invalid', logger: spy_logger) user_context = project_instance.create_user_context('user1') decisions = invalid_project.decide_for_keys(user_context, keys) expect(decisions).to eq({}) @@ -4354,8 +4375,8 @@ def callback(_args); end it 'should get only enabled decisions for keys when ENABLED_FLAGS_ONLY is true in default_decide_options' do custom_project_instance = Optimizely::Project.new( - config_body_JSON, nil, spy_logger, error_handler, - false, nil, nil, nil, nil, nil, [Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY] + datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, + default_decide_options: [Optimizely::Decide::OptimizelyDecideOption::ENABLED_FLAGS_ONLY] ) keys = %w[ boolean_single_variable_feature @@ -4392,7 +4413,7 @@ def callback(_args); end describe 'default_decide_options' do describe 'EXCLUDE_VARIABLES' do it 'should include variables when the option is not set in default_decide_options' do - custom_project_instance = Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) + custom_project_instance = Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler) experiment_to_return = config_body['experiments'][3] variation_to_return = experiment_to_return['variations'][0] decision_to_return = Optimizely::DecisionService::Decision.new( @@ -4400,8 +4421,9 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) + decision_list_to_return = [[decision_to_return, []]] allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(custom_project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(custom_project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = custom_project_instance.create_user_context('user1') decision = custom_project_instance.decide(user_context, 'multi_variate_feature') expect(decision.as_json).to include( @@ -4418,8 +4440,8 @@ def callback(_args); end it 'should exclude variables when the option is set in default_decide_options' do custom_project_instance = Optimizely::Project.new( - config_body_JSON, nil, spy_logger, error_handler, - false, nil, nil, nil, nil, nil, [Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES] + datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, + default_decide_options: [Optimizely::Decide::OptimizelyDecideOption::EXCLUDE_VARIABLES] ) experiment_to_return = config_body['experiments'][3] variation_to_return = experiment_to_return['variations'][0] @@ -4428,8 +4450,9 @@ def callback(_args); end variation_to_return, Optimizely::DecisionService::DECISION_SOURCES['FEATURE_TEST'] ) + decision_list_to_return = [[decision_to_return, []]] allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) - allow(custom_project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return) + allow(custom_project_instance.decision_service).to receive(:get_variations_for_feature_list).and_return(decision_list_to_return) user_context = custom_project_instance.create_user_context('user1') decision = custom_project_instance.decide(user_context, 'multi_variate_feature') expect(decision.as_json).to include( @@ -4448,8 +4471,8 @@ def callback(_args); end describe 'INCLUDE_REASONS' do it 'should include reasons when the option is set in default_decide_options' do custom_project_instance = Optimizely::Project.new( - config_body_JSON, nil, spy_logger, error_handler, - false, nil, nil, nil, nil, nil, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS] + datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, + default_decide_options: [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS] ) expect(custom_project_instance.notification_center).to receive(:send_notifications) .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) @@ -4472,7 +4495,9 @@ def callback(_args); end "The user 'user1' is not bucketed into any of the experiments on the feature 'multi_variate_feature'.", "Feature flag 'multi_variate_feature' is not used in a rollout." ], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) user_context = custom_project_instance.create_user_context('user1') @@ -4497,7 +4522,7 @@ def callback(_args); end end it 'should not include reasons when the option is not set in default_decide_options' do - custom_project_instance = Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) + custom_project_instance = Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler) expect(custom_project_instance.notification_center).to receive(:send_notifications) .once.with(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args) expect(custom_project_instance.notification_center).to receive(:send_notifications) @@ -4512,7 +4537,9 @@ def callback(_args); end variation_key: nil, rule_key: nil, reasons: [], - decision_event_dispatched: true + decision_event_dispatched: true, + experiment_id: nil, + variation_id: nil ) allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) user_context = custom_project_instance.create_user_context('user1') @@ -4532,7 +4559,7 @@ def callback(_args); end describe 'DISABLE_DECISION_EVENT' do it 'should send event when option is not set in default_decide_options' do - custom_project_instance = Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) + custom_project_instance = Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler) experiment_to_return = config_body['experiments'][3] variation_to_return = experiment_to_return['variations'][0] expect(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) @@ -4549,8 +4576,8 @@ def callback(_args); end it 'should not send event when option is set in default_decide_options' do custom_project_instance = Optimizely::Project.new( - config_body_JSON, nil, spy_logger, error_handler, - false, nil, nil, nil, nil, nil, [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT] + datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, + default_decide_options: [Optimizely::Decide::OptimizelyDecideOption::DISABLE_DECISION_EVENT] ) experiment_to_return = config_body['experiments'][3] variation_to_return = experiment_to_return['variations'][0] @@ -4574,7 +4601,7 @@ def callback(_args); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(disable_odp: true) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) expect(project.odp_manager.instance_variable_get('@event_manager')).to be_nil expect(project.odp_manager.instance_variable_get('@segment_manager')).to be_nil project.close @@ -4587,7 +4614,7 @@ def callback(_args); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(odp_event_flush_interval: 0) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) event_manager = project.odp_manager.instance_variable_get('@event_manager') expect(event_manager.instance_variable_get('@flush_interval')).to eq 0 project.close @@ -4599,7 +4626,7 @@ def callback(_args); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(odp_event_flush_interval: nil) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) event_manager = project.odp_manager.instance_variable_get('@event_manager') expect(event_manager.instance_variable_get('@flush_interval')).to eq 1 project.close @@ -4612,7 +4639,7 @@ def callback(_args); end .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(segments_cache_size: 5) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') expect(segment_manager.instance_variable_get('@segments_cache').capacity).to eq 5 project.close @@ -4624,7 +4651,7 @@ def callback(_args); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(segments_cache_timeout_in_secs: 5) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') expect(segment_manager.instance_variable_get('@segments_cache').timeout).to eq 5 project.close @@ -4636,7 +4663,7 @@ def callback(_args); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(segments_cache_size: 10, segments_cache_timeout_in_secs: 5) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') segments_cache = segment_manager.instance_variable_get('@segments_cache') expect(segments_cache.capacity).to eq 10 @@ -4650,7 +4677,7 @@ def callback(_args); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') segments_cache = segment_manager.instance_variable_get('@segments_cache') expect(segments_cache.capacity).to eq 10_000 @@ -4664,7 +4691,7 @@ def callback(_args); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(segments_cache_size: 0, segments_cache_timeout_in_secs: 0) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') segments_cache = segment_manager.instance_variable_get('@segments_cache') expect(segments_cache.capacity).to eq 0 @@ -4684,7 +4711,7 @@ def save(key, value); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(odp_segments_cache: CustomCache.new) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') expect(segment_manager.instance_variable_get('@segments_cache')).to be_a CustomCache project.close @@ -4698,7 +4725,7 @@ class InvalidCustomCache; end # rubocop:disable Lint/ConstantDefinitionInBlock stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(odp_segments_cache: InvalidCustomCache.new) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') expect(segment_manager.instance_variable_get('@segments_cache')).to be_a Optimizely::LRUCache @@ -4722,7 +4749,7 @@ def fetch_qualified_segments(user_key, user_value, options); end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(odp_segment_manager: CustomSegmentManager.new) - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger, error_handler, false, nil, nil, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger, error_handler: error_handler, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') expect(segment_manager).to be_a CustomSegmentManager project.fetch_qualified_segments(user_id: 'test') @@ -4738,7 +4765,7 @@ class InvalidSegmentManager; end # rubocop:disable Lint/ConstantDefinitionInBloc stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(odp_segment_manager: InvalidSegmentManager.new) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) segment_manager = project.odp_manager.instance_variable_get('@segment_manager') expect(segment_manager).to be_a Optimizely::OdpSegmentManager @@ -4761,7 +4788,7 @@ def running?; end stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(odp_event_manager: CustomEventManager.new) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) event_manager = project.odp_manager.instance_variable_get('@event_manager') expect(event_manager).to be_a CustomEventManager project.send_odp_event(action: 'test', identifiers: {wow: 'great'}) @@ -4776,7 +4803,7 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: config_body_integrations_JSON) sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(odp_event_manager: InvalidEventManager.new) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) event_manager = project.odp_manager.instance_variable_get('@event_manager') expect(event_manager).to be_a Optimizely::OdpEventManager @@ -4791,7 +4818,7 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200) expect(spy_logger).to receive(:log).once.with(Logger::DEBUG, 'ODP event queue: flushing batch size 1.') expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything) - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) project.send_odp_event(type: 'wow', action: 'great', identifiers: {amazing: 'fantastic'}, data: {}) project.close end @@ -4803,7 +4830,7 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200) expect(spy_logger).to receive(:log).once.with(Logger::DEBUG, 'ODP event queue: flushing batch size 1.') expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything) - project = Optimizely::Project.new(nil, nil, spy_logger, nil, false, nil, sdk_key) + project = Optimizely::Project.new(logger: spy_logger, sdk_key: sdk_key) sleep 0.1 until project.odp_manager.instance_variable_get('@event_manager').instance_variable_get('@event_queue').empty? @@ -4814,7 +4841,7 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock it 'should log error when odp disabled' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'ODP is not enabled.') sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(disable_odp: true) - custom_project_instance = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger, error_handler, false, nil, nil, nil, nil, nil, [], {}, sdk_settings) + custom_project_instance = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger, error_handler: error_handler, settings: sdk_settings) custom_project_instance.send_odp_event(type: 'wow', action: 'great', identifiers: {amazing: 'fantastic'}, data: {}) custom_project_instance.close end @@ -4823,7 +4850,7 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json") .to_return(status: 200, body: nil) expect(spy_logger).to receive(:log).once.with(Logger::ERROR, "Optimizely instance is not valid. Failing 'send_odp_event'.") - project = Optimizely::Project.new(nil, nil, spy_logger, nil, false, nil, sdk_key) + project = Optimizely::Project.new(logger: spy_logger, sdk_key: sdk_key) project.send_odp_event(type: 'wow', action: 'great', identifiers: {amazing: 'fantastic'}, data: {}) project.close end @@ -4833,28 +4860,28 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock .to_return(status: 200, body: config_body_integrations_JSON) expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'ODP is not enabled.') sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new(disable_odp: true) - project = Optimizely::Project.new(nil, nil, spy_logger, error_handler, false, nil, sdk_key, nil, nil, nil, [], {}, sdk_settings) + project = Optimizely::Project.new(logger: spy_logger, error_handler: error_handler, sdk_key: sdk_key, settings: sdk_settings) project.send_odp_event(type: 'wow', action: 'great', identifiers: {amazing: 'fantastic'}, data: {}) project.close end it 'should log error with invalid data' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'ODP data is not valid.') - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) project.send_odp_event(type: 'wow', action: 'great', identifiers: {amazing: 'fantastic'}, data: {'wow': {}}) project.close end it 'should log error with empty identifiers' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'ODP events must have at least one key-value pair in identifiers.') - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) project.send_odp_event(type: 'wow', action: 'great', identifiers: {}, data: {'wow': {}}) project.close end it 'should log error with nil identifiers' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'ODP events must have at least one key-value pair in identifiers.') - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) project.send_odp_event(type: 'wow', action: 'great', identifiers: nil, data: {'wow': {}}) project.close end @@ -4864,7 +4891,7 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock feature_key = 'flag-segment' user_id = 'test_user' - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) allow(project.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event)) expect(project.odp_manager).not_to receive(:send_event) @@ -4881,20 +4908,20 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock it 'should log error with nil action' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'ODP action is not valid (cannot be empty).') - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) project.send_odp_event(type: 'wow', action: nil, identifiers: {amazing: 'fantastic'}, data: {}) project.close end it 'should log error with empty string action' do expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'ODP action is not valid (cannot be empty).') - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) project.send_odp_event(type: 'wow', action: '', identifiers: {amazing: 'fantastic'}, data: {}) project.close end it 'should use default with nil type' do - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) expect(project.odp_manager).to receive('send_event').with(type: 'fullstack', action: 'great', identifiers: {amazing: 'fantastic'}, data: {}) project.send_odp_event(type: nil, action: 'great', identifiers: {amazing: 'fantastic'}, data: {}) @@ -4904,7 +4931,7 @@ class InvalidEventManager; end # rubocop:disable Lint/ConstantDefinitionInBlock end it 'should use default with empty string type' do - project = Optimizely::Project.new(config_body_integrations_JSON, nil, spy_logger) + project = Optimizely::Project.new(datafile: config_body_integrations_JSON, logger: spy_logger) expect(project.odp_manager).to receive('send_event').with(type: 'fullstack', action: 'great', identifiers: {amazing: 'fantastic'}, data: {}) project.send_odp_event(type: '', action: 'great', identifiers: {amazing: 'fantastic'}, data: {}) diff --git a/spec/user_condition_evaluator_spec.rb b/spec/user_condition_evaluator_spec.rb index 7aef929e..0d74e514 100644 --- a/spec/user_condition_evaluator_spec.rb +++ b/spec/user_condition_evaluator_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2019-2020, Optimizely and contributors +# Copyright 2019-2020, 2023, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ let(:error_handler) { Optimizely::NoOpErrorHandler.new } let(:spy_logger) { spy('logger') } let(:event_processor) { Optimizely::ForwardingEventProcessor.new(Optimizely::EventDispatcher.new) } - let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler, false, nil, nil, nil, nil, event_processor) } + let(:project_instance) { Optimizely::Project.new(datafile: config_body_JSON, logger: spy_logger, error_handler: error_handler, event_processor: event_processor) } let(:user_context) { project_instance.create_user_context('some-user', {}) } after(:example) { project_instance.close } diff --git a/spec/user_profile_tracker_spec.rb b/spec/user_profile_tracker_spec.rb new file mode 100644 index 00000000..85515bb1 --- /dev/null +++ b/spec/user_profile_tracker_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rspec' + +RSpec.describe Optimizely::UserProfileTracker do + let(:user_id) { 'test_user' } + let(:mock_user_profile_service) { instance_double('UserProfileService') } + let(:mock_logger) { instance_double('Logger') } + let(:user_profile_tracker) { described_class.new(user_id, mock_user_profile_service, mock_logger) } + + describe '#initialize' do + it 'initializes with a user ID and default values' do + tracker = described_class.new(user_id) + expect(tracker.user_profile[:user_id]).to eq(user_id) + expect(tracker.user_profile[:experiment_bucket_map]).to eq({}) + end + + it 'accepts a user profile service and logger' do + expect(user_profile_tracker.instance_variable_get(:@user_profile_service)).to eq(mock_user_profile_service) + expect(user_profile_tracker.instance_variable_get(:@logger)).to eq(mock_logger) + end + end + + describe '#load_user_profile' do + it 'loads the user profile from the service if provided' do + expected_profile = { + user_id: user_id, + experiment_bucket_map: {'111127' => {variation_id: '111128'}} + } + allow(mock_user_profile_service).to receive(:lookup).with(user_id).and_return(expected_profile) + user_profile_tracker.load_user_profile + expect(user_profile_tracker.user_profile).to eq(expected_profile) + end + + it 'handles errors during lookup and logs them' do + allow(mock_user_profile_service).to receive(:lookup).with(user_id).and_raise(StandardError.new('lookup error')) + allow(mock_logger).to receive(:log) + + reasons = [] + user_profile_tracker.load_user_profile(reasons) + expect(reasons).to include("Error while looking up user profile for user ID 'test_user': lookup error.") + expect(mock_logger).to have_received(:log).with(Logger::ERROR, "Error while looking up user profile for user ID 'test_user': lookup error.") + end + + it 'does nothing if reasons array is nil' do + expect(mock_user_profile_service).not_to receive(:lookup) + user_profile_tracker.load_user_profile(nil) + end + end + + describe '#update_user_profile' do + let(:experiment_id) { '111127' } + let(:variation_id) { '111128' } + + before do + allow(mock_logger).to receive(:log) + end + + it 'updates the experiment bucket map with the given experiment and variation IDs' do + user_profile_tracker.update_user_profile(experiment_id, variation_id) + + # Verify the experiment and variation were added + expect(user_profile_tracker.user_profile[:experiment_bucket_map][experiment_id][:variation_id]).to eq(variation_id) + # Verify the profile_updated flag was set + expect(user_profile_tracker.instance_variable_get(:@profile_updated)).to eq(true) + # Verify a log message was recorded + expect(mock_logger).to have_received(:log).with(Logger::INFO, "Updated variation ID #{variation_id} of experiment ID #{experiment_id} for user 'test_user'.") + end + end + + describe '#save_user_profile' do + it 'saves the user profile if updates were made and service is available' do + allow(mock_user_profile_service).to receive(:save) + allow(mock_logger).to receive(:log) + + user_profile_tracker.update_user_profile('111127', '111128') + user_profile_tracker.save_user_profile + + expect(mock_user_profile_service).to have_received(:save).with(user_profile_tracker.user_profile) + expect(mock_logger).to have_received(:log).with(Logger::INFO, "Saved user profile for user 'test_user'.") + end + + it 'does not save the user profile if no updates were made' do + allow(mock_user_profile_service).to receive(:save) + + user_profile_tracker.save_user_profile + expect(mock_user_profile_service).not_to have_received(:save) + end + + it 'handles errors during save and logs them' do + allow(mock_user_profile_service).to receive(:save).and_raise(StandardError.new('save error')) + allow(mock_logger).to receive(:log) + + user_profile_tracker.update_user_profile('111127', '111128') + user_profile_tracker.save_user_profile + + expect(mock_logger).to have_received(:log).with(Logger::ERROR, "Failed to save user profile for user 'test_user': save error.") + end + end +end