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] "
+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 @@
+
+## 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/lint_markdown.yml b/.github/workflows/lint_markdown.yml
deleted file mode 100644
index 9089b508..00000000
--- a/.github/workflows/lint_markdown.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-name: Reusable action of linting markdown files
-
-on: [workflow_call]
-
-jobs:
- lint:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
- - name: Set up Ruby
- uses: ruby/setup-ruby@v1
- with:
- ruby-version: '3.1'
- bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- - name: Install gem
- run: |
- gem install awesome_bot
- - name: Run tests
- run: find . -type f -name '*.md' -exec awesome_bot {} \;
diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml
index 6c99cefd..98f73108 100644
--- a/.github/workflows/ruby.yml
+++ b/.github/workflows/ruby.yml
@@ -1,4 +1,4 @@
-name: Ruby
+name: build
on:
push:
@@ -7,9 +7,6 @@ on:
branches: [ master ]
jobs:
- lint_markdown_files:
- uses: optimizely/ruby-sdk/.github/workflows/lint_markdown.yml@master
-
integration_tests:
uses: optimizely/ruby-sdk/.github/workflows/integration_test.yml@master
secrets:
@@ -28,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/.github/workflows/source_clear_crone.yml b/.github/workflows/source_clear_crone.yml
index 4a9b2dcf..4ec1475b 100644
--- a/.github/workflows/source_clear_crone.yml
+++ b/.github/workflows/source_clear_crone.yml
@@ -12,6 +12,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.1'
+ bundler-cache: true
- name: Source clear scan
env:
SRCCLR_API_TOKEN: ${{ secrets.SRCCLR_API_TOKEN }}
diff --git a/.github/workflows/ticket_reference_check.yml b/.github/workflows/ticket_reference_check.yml
index d2829e0c..b7d52780 100644
--- a/.github/workflows/ticket_reference_check.yml
+++ b/.github/workflows/ticket_reference_check.yml
@@ -13,4 +13,4 @@ jobs:
- name: Check for Jira ticket reference
uses: optimizely/github-action-ticket-reference-checker-public@master
with:
- bodyRegex: 'OASIS-(?\d+)'
+ bodyRegex: 'FSSDK-(?\d+)'
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 4440524a..0330fa06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,132 @@
# 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
+
+### New Features
+
+The 5.0.0-beta 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)
+
+### Breaking Changes
+
+* `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)).
+
+## 4.0.1
+March 13th, 2023
+
+We updated our README.md and other non-functional code to reflect that this SDK supports both Optimizely Feature Experimentation and Optimizely Full Stack. ([#327](https://github.com/optimizely/ruby-sdk/pull/327))
## 4.0.0
August 4, 2022
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 36ecd396..be5e0613 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,25 @@
# Optimizely Ruby SDK
-[](https://travis-ci.org/optimizely/ruby-sdk)
+
+[](https://github.com/optimizely/ruby-sdk/actions/workflows/ruby.yml?query=branch%3Amaster)
[](https://coveralls.io/github/optimizely/ruby-sdk)
[](http://www.apache.org/licenses/LICENSE-2.0)
-This repository houses the Ruby SDK for use with Optimizely Full Stack and Optimizely Rollouts.
-Optimizely Full Stack is A/B testing and feature flag management for product development teams. Experiment in any application. Make every feature on your roadmap an opportunity to learn. Learn more at https://www.optimizely.com/platform/full-stack/, or see the [documentation](https://docs.developers.optimizely.com/full-stack/docs).
+This repository houses the Ruby SDK for use with Optimizely Feature Experimentation and Optimizely Full Stack (legacy).
+
+Optimizely Feature Experimentation is an A/B testing and feature management tool for product development teams that enables you to experiment at every step. Using Optimizely Feature Experimentation allows for every feature on your roadmap to be an opportunity to discover hidden insights. Learn more at [Optimizely.com](https://www.optimizely.com/products/experiment/feature-experimentation/), or see the [developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome).
+
+Optimizely Rollouts is [free feature flags](https://www.optimizely.com/free-feature-flagging/) for development teams. You can easily roll out and roll back features in any application without code deploys, mitigating risk for every feature on your roadmap.
+
+## Get Started
-Optimizely Rollouts is free feature flags for development teams. Easily roll out and roll back features in any application without code deploys. Mitigate risk for every feature on your roadmap. Learn more at https://www.optimizely.com/rollouts/, or see the [documentation](https://docs.developers.optimizely.com/rollouts/docs).
+Refer to the [Ruby SDK's developer documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/ruby-sdk) for detailed instructions on getting started with using the SDK.
-## Getting Started
+### Requirements
-### Installing the SDK
+* Ruby 3.0+
+
+### Install the SDK
The SDK is available through [RubyGems](https://rubygems.org/gems/optimizely-sdk). To install:
@@ -20,9 +28,11 @@ gem install optimizely-sdk
```
### Feature Management Access
-To access the Feature Management configuration in the Optimizely dashboard, please contact your Optimizely account executive.
+To access the Feature Management configuration in the Optimizely dashboard, please contact your Optimizely customer success manager.
+
+## Use the Ruby SDK
-### Using the SDK
+### Initialization
You can initialize the Optimizely instance in two ways: directly with a datafile, or by using a factory class, `OptimizelyFactory`, which provides methods to create an Optimizely instance with the default configuration.
@@ -30,15 +40,15 @@ 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.
- ```
- optimizely_instance = Optimizely::Project.new(datafile)
+ ```ruby
+ optimizely_instance = Optimizely::Project.new(datafile: datafile)
```
#### Initialization by OptimizelyFactory
1. Initialize Optimizely by providing an `sdk_key` and an optional `datafile`. This will initialize an HTTPConfigManager that makes an HTTP GET request to the URL (formed using your provided `sdk_key` and the default datafile CDN url template) to asynchronously download the project datafile at regular intervals and update ProjectConfig when a new datafile is received.
- ```
+ ```ruby
optimizely_instance = Optimizely::OptimizelyFactory.default_instance('put_your_sdk_key_here', datafile)
```
@@ -46,14 +56,14 @@ You can initialize the Optimizely instance in two ways: directly with a datafile
2. Initialize Optimizely by providing a Config Manager that implements a `config` method. You can customize our `HTTPConfigManager` as needed.
- ```
+ ```ruby
custom_config_manager = CustomConfigManager.new
optimizely_instance = Optimizely::OptimizelyFactory.default_instance_with_config_manager(custom_config_manager)
```
3. Initialize Optimizely with required `sdk_key` and other optional arguments.
- ```
+ ```ruby
optimizely_instance = Optimizely::OptimizelyFactory.custom_instance(
sdk_key,
datafile,
@@ -66,15 +76,16 @@ You can initialize the Optimizely instance in two ways: directly with a datafile
notification_center,
event_processor
)
- ```
+ ```
+**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.
-~~~~~~
+```ruby
http_project_config_manager = Optimizely::HTTPProjectConfigManager.new(
sdk_key: nil,
url: nil,
@@ -91,7 +102,7 @@ The `HTTPConfigManager` asynchronously polls for datafiles from a specified URL
datafile_access_token: nil,
proxy_config: nil
)
-~~~~~~
+```
**Note:** You must provide either the `sdk_key` or URL. If you provide both, the URL takes precedence.
**sdk_key**
@@ -107,7 +118,7 @@ The polling interval is used to specify a fixed delay between consecutive HTTP r
A string with placeholder `{sdk_key}` can be provided so that this template along with the provided `sdk_key` is used to form the target URL.
**start_by_default**
-Boolean flag used to start the `AsyncScheduler` for datafile polling if set to `True`.
+Boolean flag used to start the `AsyncScheduler` for datafile polling if set to `true`.
**blocking_timeout**
The blocking timeout period is used to specify a maximum time to wait for initial bootstrapping. Valid blocking timeout period is between 1 and 2592000 seconds. Default is 15 seconds.
@@ -132,19 +143,22 @@ The following properties can be set to override the default configurations for `
| start_by_default | true | Boolean flag to specify if datafile polling should start right away as soon as `HTTPConfigManager` initializes
| blocking_timeout | 15 seconds | Maximum time in seconds to block the `config` call until config has been initialized
-A notification signal will be triggered whenever a _new_ datafile is fetched and Project Config is updated. To subscribe to these notifications, use the `notification_center.add_notification_listener(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:OPTIMIZELY_CONFIG_UPDATE], @callback)`
+A notification signal will be triggered whenever a _new_ datafile is fetched and Project Config is updated. To subscribe to these notifications, use the
+```ruby
+notification_center.add_notification_listener(Optimizely::NotificationCenter::NOTIFICATION_TYPES[:OPTIMIZELY_CONFIG_UPDATE], @callback)
+```
#### BatchEventProcessor
-[BatchEventProcessor](https://github.com/optimizely/ruby-sdk/blob/master/lib/optimizely/event/batch_event_processor.rb) is a batched implementation of the [EventProcessor](https://github.com/optimizely/ruby-sdk/blob/master/lib/optimizely/event/event_processor.rb)
+[BatchEventProcessor](https://github.com/optimizely/ruby-sdk/blob/master/lib/optimizely/event/batch_event_processor.rb) is a batched implementation of the [EventProcessor](https://github.com/optimizely/ruby-sdk/blob/master/lib/optimizely/event/event_processor.rb)
* Events passed to the `BatchEventProcessor` are immediately added to a `Queue`.
* The `BatchEventProcessor` maintains a single consumer thread that pulls events off of the `Queue` and buffers them for either a configured batch size or for a maximum duration before the resulting `LogEvent` is sent to the `NotificationCenter`.
-##### Use BatchEventProcessor
-~~~~~~
+#### Use BatchEventProcessor
+```ruby
event_processor = Optimizely::BatchEventProcessor.new(
event_queue: SizedQueue.new(10),
event_dispatcher: event_dispatcher,
@@ -153,7 +167,7 @@ event_processor = Optimizely::BatchEventProcessor.new(
logger: logger,
notification_center: notification_center
)
-~~~~~~
+```
#### Advanced configuration
The following properties can be used to customize the `BatchEventProcessor` configuration.
@@ -176,9 +190,10 @@ If you enable event batching, make sure that you call the `close` method, `optim
| -- | --
| `close()` | Stops all timers and flushes the event queue. This method will also stop any timers that are happening for the datafile manager.
-See the Optimizely Full Stack [developer documentation](http://developers.optimizely.com/server/reference/index.html) to learn how to set up your first Full Stack project and use the SDK.
+For Further details see the Optimizely [Feature Experimentation documentation](https://docs.developers.optimizely.com/experimentation/v4.0.0-full-stack/docs/welcome)
+to learn how to set up your first Ruby project and use the SDK.
-## Development
+## SDK Development
### Building the SDK
@@ -188,9 +203,9 @@ To build a local copy of the gem which will be output to `/pkg`:
rake build
```
-### Unit tests
+### Unit Tests
-##### Running all tests
+#### Running all tests
You can run all unit tests with:
```
@@ -202,47 +217,74 @@ rake spec
Please see [CONTRIBUTING](CONTRIBUTING.md).
### Credits
+
This software incorporates code from the following open source projects:
-**Httparty** [https://github.com/jnunemaker/httparty](https://github.com/jnunemaker/httparty)
-Copyright © 2008 John Nunemaker
+**Httparty** [https://github.com/jnunemaker/httparty](https://github.com/jnunemaker/httparty)
+Copyright © 2008 John Nunemaker
License (MIT): [https://github.com/jnunemaker/httparty/blob/master/MIT-LICENSE](https://github.com/jnunemaker/httparty/blob/master/MIT-LICENSE)
-**JSON Schema Validator** [https://github.com/ruby-json-schema/json-schema](https://github.com/ruby-json-schema/json-schema)
-Copyright © 2010-2011, Lookingglass Cyber Solutions
+**JSON Schema Validator** [https://github.com/ruby-json-schema/json-schema](https://github.com/ruby-json-schema/json-schema)
+Copyright © 2010-2011, Lookingglass Cyber Solutions
License (MIT): [https://github.com/ruby-json-schema/json-schema/blob/master/LICENSE.md](https://github.com/ruby-json-schema/json-schema/blob/master/LICENSE.md)
-**Murmurhash3** [https://github.com/funny-falcon/murmurhash3-ruby](https://github.com/funny-falcon/murmurhash3-ruby)
-Copyright © 2012 Sokolov Yura 'funny-falcon'
+**Murmurhash3** [https://github.com/funny-falcon/murmurhash3-ruby](https://github.com/funny-falcon/murmurhash3-ruby)
+Copyright © 2012 Sokolov Yura 'funny-falcon'
License (MIT): [https://github.com/funny-falcon/murmurhash3-ruby/blob/master/LICENSE](https://github.com/funny-falcon/murmurhash3-ruby/blob/master/LICENSE)
-
### Additional Code
+
This software may be used with additional code that is separately downloaded by you. _These components are subject to
their own license terms, which you should review carefully_.
-**Bundler** [https://github.com/bundler/bundler](https://github.com/bundler/bundler)
-Copyright © 2008-2018 Andre Arko, Engine Yard, et al
+**Bundler** [https://github.com/bundler/bundler](https://github.com/bundler/bundler)
+Copyright © 2008-2018 Andre Arko, Engine Yard, et al
License (MIT): [https://github.com/bundler/bundler/blob/master/LICENSE.md](https://github.com/bundler/bundler/blob/master/LICENSE.md)
-**Coveralls** [https://github.com/lemurheavy/coveralls-ruby](https://github.com/lemurheavy/coveralls-ruby)
-Copyright © 2012 Wil Gieseler
+**Coveralls** [https://github.com/lemurheavy/coveralls-ruby](https://github.com/lemurheavy/coveralls-ruby)
+Copyright © 2012 Wil Gieseler
License (MIT): [https://github.com/lemurheavy/coveralls-ruby/blob/master/LICENSE](https://github.com/lemurheavy/coveralls-ruby/blob/master/LICENSE)
-**Rake** [https://github.com/ruby/rake](https://github.com/ruby/rake)
-Copyright © 2004-2017 Jim Weirich
-License (MIT): [https://github.com/ruby/rake/blob/master/MIT-LICENSE](https://github.com/ruby/rake/blob/master/MIT-LICENSE)
+**Rake** [https://github.com/ruby/rake](https://github.com/ruby/rake)
+Copyright © 2004-2017 Jim Weirich
+License (MIT): [https://github.com/ruby/rake/blob/master/MIT-LICENSE](https://github.com/ruby/rake/blob/master/MIT-LICENSE)
-**RSpec** [https://github.com/rspec/rspec](https://github.com/rspec/rspec)
-Copyright © 2009 Chad Humphries, David Chelimsky
-Copyright © 2006 David Chelimsky, The RSpec Development Team
-Copyright © 2005 Steven Baker
-License (MIT): [https://github.com/rspec/rspec/blob/master/LICENSE.md](https://github.com/rspec/rspec/blob/master/LICENSE.md)
+**RSpec** [https://github.com/rspec/rspec](https://github.com/rspec/rspec)
+Copyright © 2009 Chad Humphries, David Chelimsky
+Copyright © 2006 David Chelimsky, The RSpec Development Team
+Copyright © 2005 Steven Baker
+License (MIT): [https://github.com/rspec/rspec/blob/master/LICENSE.md](https://github.com/rspec/rspec/blob/master/LICENSE.md)
-**RuboCop** [https://github.com/rubocop-hq/rubocop](https://github.com/rubocop-hq/rubocop)
-Copyright © 2012-19 Bozhidar Batsov
+**RuboCop** [https://github.com/rubocop-hq/rubocop](https://github.com/rubocop-hq/rubocop)
+Copyright © 2012-19 Bozhidar Batsov
License (MIT): [https://github.com/rubocop-hq/rubocop/blob/master/LICENSE.txt](https://github.com/rubocop-hq/rubocop/blob/master/LICENSE.txt)
-**WebMock** [https://github.com/bblimke/webmock](https://github.com/bblimke/webmock)
-Copyright © 2009-2010 Bartosz Blimke
+**WebMock** [https://github.com/bblimke/webmock](https://github.com/bblimke/webmock)
+Copyright © 2009-2010 Bartosz Blimke
License (MIT): [https://github.com/bblimke/webmock/blob/master/LICENSE](https://github.com/bblimke/webmock/blob/master/LICENSE)
+
+### Other Optimizely SDKs
+
+- Agent - https://github.com/optimizely/agent
+
+- Android - https://github.com/optimizely/android-sdk
+
+- C# - https://github.com/optimizely/csharp-sdk
+
+- Flutter - https://github.com/optimizely/optimizely-flutter-sdk
+
+- Go - https://github.com/optimizely/go-sdk
+
+- Java - https://github.com/optimizely/java-sdk
+
+- JavaScript - https://github.com/optimizely/javascript-sdk
+
+- PHP - https://github.com/optimizely/php-sdk
+
+- Python - https://github.com/optimizely/python-sdk
+
+- React - https://github.com/optimizely/react-sdk
+
+- Ruby - https://github.com/optimizely/ruby-sdk
+
+- Swift - https://github.com/optimizely/swift-sdk
diff --git a/lib/optimizely.rb b/lib/optimizely.rb
index 22d44548..f2e1dd82 100644
--- a/lib/optimizely.rb
+++ b/lib/optimizely.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
#
-# Copyright 2016-2022, Optimizely and contributors
+# Copyright 2016-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 @@
require_relative 'optimizely/decision_service'
require_relative 'optimizely/error_handler'
require_relative 'optimizely/event_builder'
-require_relative 'optimizely/event/forwarding_event_processor'
+require_relative 'optimizely/event/batch_event_processor'
require_relative 'optimizely/event/event_factory'
require_relative 'optimizely/event/user_event_factory'
require_relative 'optimizely/event_dispatcher'
@@ -36,8 +36,13 @@
require_relative 'optimizely/helpers/variable_type'
require_relative 'optimizely/logger'
require_relative 'optimizely/notification_center'
+require_relative 'optimizely/notification_center_registry'
require_relative 'optimizely/optimizely_config'
require_relative 'optimizely/optimizely_user_context'
+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
@@ -46,7 +51,7 @@ class Project
attr_reader :notification_center
# @api no-doc
attr_reader :config_manager, :decision_service, :error_handler, :event_dispatcher,
- :event_processor, :logger, :stopped
+ :event_processor, :logger, :odp_manager, :stopped
# Constructor for Projects.
#
@@ -62,25 +67,31 @@ class Project
# @param config_manager - Optional Responds to 'config' method.
# @param notification_center - Optional Instance of NotificationCenter.
# @param event_processor - Optional Responds to process.
-
- 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 = []
+ # @param default_decide_options: Optional default decision options.
+ # @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(
+ 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
@event_dispatcher = event_dispatcher || EventDispatcher.new(logger: @logger, error_handler: @error_handler)
@user_profile_service = user_profile_service
@default_decide_options = []
+ @sdk_settings = settings
if default_decide_options.is_a? Array
@default_decide_options = default_decide_options.clone
@@ -89,6 +100,11 @@ def initialize( # rubocop:disable Metrics/ParameterLists
@default_decide_options = []
end
+ unless event_processor_options.is_a? Hash
+ @logger.log(Logger::DEBUG, 'Provided event processor options is not a hash.')
+ event_processor_options = {}
+ end
+
begin
validate_instantiation_options
rescue InvalidInputError => e
@@ -98,7 +114,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists
@notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(@logger, @error_handler)
- @config_manager = if config_manager.respond_to?(:config)
+ @config_manager = if config_manager.respond_to?(:config) && config_manager.respond_to?(:sdk_key)
config_manager
elsif sdk_key
HTTPProjectConfigManager.new(
@@ -113,12 +129,20 @@ def initialize( # rubocop:disable Metrics/ParameterLists
StaticProjectConfigManager.new(datafile, @logger, @error_handler, skip_json_validation)
end
+ setup_odp!(@config_manager.sdk_key)
+
@decision_service = DecisionService.new(@logger, @user_profile_service)
@event_processor = if event_processor.respond_to?(:process)
event_processor
else
- ForwardingEventProcessor.new(@event_dispatcher, @logger, @notification_center)
+ BatchEventProcessor.new(
+ event_dispatcher: @event_dispatcher,
+ logger: @logger,
+ notification_center: @notification_center,
+ batch_size: event_processor_options[:batch_size] || BatchEventProcessor::DEFAULT_BATCH_SIZE,
+ flush_interval: event_processor_options[:flush_interval] || BatchEventProcessor::DEFAULT_BATCH_INTERVAL
+ )
end
end
@@ -149,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
@@ -226,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
@@ -237,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(
@@ -258,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
@@ -275,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
@@ -285,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
@@ -507,7 +598,7 @@ def is_feature_enabled(feature_flag_key, user_id, attributes = nil)
return false
end
- user_context = create_user_context(user_id, attributes)
+ user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
feature_enabled = false
@@ -541,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,
@@ -747,7 +838,7 @@ def get_all_feature_variables(feature_flag_key, user_id, attributes = nil)
return nil
end
- user_context = create_user_context(user_id, attributes)
+ user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
variation = decision ? decision['variation'] : nil
feature_enabled = variation ? variation['featureEnabled'] : false
@@ -769,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,
@@ -816,6 +907,7 @@ def close
@stopped = true
@config_manager.stop! if @config_manager.respond_to?(:stop!)
@event_processor.stop! if @event_processor.respond_to?(:stop!)
+ @odp_manager.stop!
end
def get_optimizely_config
@@ -865,8 +957,54 @@ 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
+
+ # Send an event to the ODP server.
+ #
+ # @param action - the event action name. Cannot be nil or empty string.
+ # @param identifiers - a hash for identifiers. The caller must provide at least one key-value pair.
+ # @param type - the event type (default = "fullstack").
+ # @param data - a hash for associated data. The default event data will be added to this data before sending to the ODP server.
+
+ def send_odp_event(action:, identifiers:, type: Helpers::Constants::ODP_MANAGER_CONFIG[:EVENT_TYPE], data: {})
+ unless identifiers.is_a?(Hash) && !identifiers.empty?
+ @logger.log(Logger::ERROR, 'ODP events must have at least one key-value pair in identifiers.')
+ return
+ end
+
+ unless is_valid
+ @logger.log(Logger::ERROR, InvalidProjectConfigError.new('send_odp_event').message)
+ return
end
+
+ if action.nil? || action.empty?
+ @logger.log(Logger::ERROR, Helpers::Constants::ODP_LOGS[:ODP_INVALID_ACTION])
+ return
+ end
+
+ type = Helpers::Constants::ODP_MANAGER_CONFIG[:EVENT_TYPE] if type.nil? || type.empty?
+
+ @odp_manager.send_event(type: type, action: action, identifiers: identifiers, data: data)
+ end
+
+ def identify_user(user_id:)
+ unless is_valid
+ @logger.log(Logger::ERROR, InvalidProjectConfigError.new('identify_user').message)
+ return
+ end
+
+ @odp_manager.identify_user(user_id: user_id)
+ end
+
+ def fetch_qualified_segments(user_id:, options: [])
+ unless is_valid
+ @logger.log(Logger::ERROR, InvalidProjectConfigError.new('fetch_qualified_segments').message)
+ return
+ end
+
+ @odp_manager.fetch_qualified_segments(user_id: user_id, options: options)
end
private
@@ -888,8 +1026,11 @@ def get_variation_with_config(experiment_key, user_id, attributes, config)
return nil unless user_inputs_valid?(attributes)
- user_context = create_user_context(user_id, attributes)
- variation_id, = @decision_service.get_variation(config, experiment_id, user_context)
+ user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
+ 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)
@@ -899,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
)
@@ -955,7 +1096,7 @@ def get_feature_variable_for_type(feature_flag_key, variable_key, variable_type,
return nil
end
- user_context = create_user_context(user_id, attributes)
+ user_context = OptimizelyUserContext.new(self, user_id, attributes, identify: false)
decision, = @decision_service.get_variation_for_feature(config, feature_flag, user_context)
variation = decision ? decision['variation'] : nil
feature_enabled = variation ? variation['featureEnabled'] : false
@@ -974,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,
@@ -1126,5 +1267,67 @@ def send_impression(config, experiment, variation_key, flag_key, rule_key, enabl
def project_config
@config_manager.config
end
+
+ def update_odp_config_on_datafile_update
+ # if datafile isn't ready, expects to be called again by the internal notification_center
+ return if @config_manager.respond_to?(:ready?) && !@config_manager.ready?
+
+ config = @config_manager&.config
+ return unless config
+
+ @odp_manager.update_odp_config(config.public_key_for_odp, config.host_for_odp, config.all_segments)
+ end
+
+ def setup_odp!(sdk_key)
+ unless @sdk_settings.is_a? Optimizely::Helpers::OptimizelySdkSettings
+ @logger.log(Logger::DEBUG, 'Provided sdk_settings is not an OptimizelySdkSettings instance.') unless @sdk_settings.nil?
+ @sdk_settings = Optimizely::Helpers::OptimizelySdkSettings.new
+ end
+
+ if !@sdk_settings.odp_segment_manager.nil? && !Helpers::Validator.segment_manager_valid?(@sdk_settings.odp_segment_manager)
+ @logger.log(Logger::ERROR, 'Invalid ODP segment manager, reverting to default.')
+ @sdk_settings.odp_segment_manager = nil
+ end
+
+ if !@sdk_settings.odp_event_manager.nil? && !Helpers::Validator.event_manager_valid?(@sdk_settings.odp_event_manager)
+ @logger.log(Logger::ERROR, 'Invalid ODP event manager, reverting to default.')
+ @sdk_settings.odp_event_manager = nil
+ end
+
+ if !@sdk_settings.odp_segments_cache.nil? && !Helpers::Validator.segments_cache_valid?(@sdk_settings.odp_segments_cache)
+ @logger.log(Logger::ERROR, 'Invalid ODP segments cache, reverting to default.')
+ @sdk_settings.odp_segments_cache = nil
+ end
+
+ # no need to instantiate a cache if a custom cache or segment manager is provided.
+ if !@sdk_settings.odp_disabled && @sdk_settings.odp_segment_manager.nil?
+ @sdk_settings.odp_segments_cache ||= LRUCache.new(
+ @sdk_settings.segments_cache_size,
+ @sdk_settings.segments_cache_timeout_in_secs
+ )
+ end
+
+ @odp_manager = OdpManager.new(
+ disable: @sdk_settings.odp_disabled,
+ segment_manager: @sdk_settings.odp_segment_manager,
+ event_manager: @sdk_settings.odp_event_manager,
+ segments_cache: @sdk_settings.odp_segments_cache,
+ fetch_segments_timeout: @sdk_settings.fetch_segments_timeout,
+ odp_event_timeout: @sdk_settings.odp_event_timeout,
+ odp_flush_interval: @sdk_settings.odp_flush_interval,
+ logger: @logger
+ )
+
+ return if @sdk_settings.odp_disabled
+
+ Optimizely::NotificationCenterRegistry
+ .get_notification_center(sdk_key, @logger)
+ &.add_notification_listener(
+ NotificationCenter::NOTIFICATION_TYPES[:OPTIMIZELY_CONFIG_UPDATE],
+ method(:update_odp_config_on_datafile_update)
+ )
+
+ update_odp_config_on_datafile_update
+ end
end
end
diff --git a/lib/optimizely/audience.rb b/lib/optimizely/audience.rb
index 130e5d95..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|
@@ -112,12 +112,12 @@ def get_segments(conditions)
# Returns array of segment names.
segments = []
- conditions.each do |condition|
- case condition
- when Array
+ case conditions
+ when Hash
+ segments.push(conditions['value']) if conditions.fetch('match', nil) == 'qualified'
+ when Array
+ conditions.each do |condition|
segments.concat @parse_segments.call(condition)
- when Hash
- segments.push(condition['value']) if condition.fetch('match', nil) == 'qualified'
end
end
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 5d939ea4..25357133 100644
--- a/lib/optimizely/config/datafile_project_config.rb
+++ b/lib/optimizely/config/datafile_project_config.rb
@@ -93,7 +93,7 @@ def initialize(datafile, logger, error_handler)
@experiment_key_map = generate_key_map(@experiments, 'key')
@experiment_id_map = generate_key_map(@experiments, 'id')
@audience_id_map = generate_key_map(@audiences, 'id')
- @integration_key_map = generate_key_map(@integrations, 'key')
+ @integration_key_map = generate_key_map(@integrations, 'key', first_value: true)
@audience_id_map = @audience_id_map.merge(generate_key_map(@typed_audiences, 'id')) unless @typed_audiences.empty?
@variation_id_map = {}
@variation_key_map = {}
@@ -184,20 +184,19 @@ def self.create(datafile, logger, error_handler, skip_json_validation)
# skip_json_validation - Optional boolean param which allows skipping JSON schema
# validation upon object invocation. By default JSON schema validation will be performed.
# Returns instance of DatafileProjectConfig, nil otherwise.
+ logger ||= SimpleLogger.new
if !skip_json_validation && !Helpers::Validator.datafile_valid?(datafile)
- default_logger = SimpleLogger.new
- default_logger.log(Logger::ERROR, InvalidInputError.new('datafile').message)
+ logger.log(Logger::ERROR, InvalidInputError.new('datafile').message)
return nil
end
begin
config = new(datafile, logger, error_handler)
rescue StandardError => e
- default_logger = SimpleLogger.new
error_to_handle = e.instance_of?(InvalidDatafileVersionError) ? e : InvalidInputError.new('datafile')
error_msg = error_to_handle.message
- default_logger.log(Logger::ERROR, error_msg)
+ logger.log(Logger::ERROR, error_msg)
error_handler.handle_error error_to_handle
return nil
end
@@ -224,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
@@ -239,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
@@ -254,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
@@ -269,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
@@ -284,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
@@ -309,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
@@ -332,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
@@ -355,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
@@ -378,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
@@ -398,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)
@@ -421,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
@@ -440,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
@@ -525,15 +540,19 @@ def generate_feature_variation_map(feature_flags)
flag_variation_map
end
- def generate_key_map(array, key)
+ def generate_key_map(array, key, first_value: false)
# Helper method to generate map from key to hash in array of hashes
#
# array - Array consisting of hash
# key - Key in each hash which will be key in the map
+ # first_value - Determines which value to save if there are duplicate keys. By default the last instance of the key
+ # will be saved. Set to true to save the first key/value encountered.
#
# Returns map mapping key to hash
- Hash[array.map { |obj| [obj[key], obj] }]
+ array
+ .group_by { |obj| obj[key] }
+ .transform_values { |group| first_value ? group.first : group.last }
end
end
end
diff --git a/lib/optimizely/config_manager/http_project_config_manager.rb b/lib/optimizely/config_manager/http_project_config_manager.rb
index 790353ab..03e177b5 100644
--- a/lib/optimizely/config_manager/http_project_config_manager.rb
+++ b/lib/optimizely/config_manager/http_project_config_manager.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.
@@ -33,12 +33,12 @@ module Optimizely
class HTTPProjectConfigManager < ProjectConfigManager
# Config manager that polls for the datafile and updated ProjectConfig based on an update interval.
- attr_reader :stopped
+ attr_reader :stopped, :sdk_key
# Initialize config manager. One of sdk_key or url has to be set to be able to use.
#
- # sdk_key - Optional string uniquely identifying the datafile. It's required unless a URL is passed in.
- # datafile: Optional JSON string representing the project.
+ # sdk_key - Optional string uniquely identifying the datafile. It's required unless a datafile with sdk_key is passed in.
+ # datafile - Optional JSON string representing the project. If nil, sdk_key is required.
# polling_interval - Optional floating point number representing time interval in seconds
# at which to request datafile and set ProjectConfig.
# blocking_timeout - Optional Time in seconds to block the config call until config object has been initialized.
@@ -83,6 +83,10 @@ def initialize(
@notification_center = notification_center.is_a?(Optimizely::NotificationCenter) ? notification_center : NotificationCenter.new(@logger, @error_handler)
@optimizely_config = nil
@config = datafile.nil? ? nil : DatafileProjectConfig.create(datafile, @logger, @error_handler, @skip_json_validation)
+ @sdk_key = sdk_key || @config&.sdk_key
+
+ raise MissingSdkKeyError if @sdk_key.nil?
+
@mutex = Mutex.new
@resource = ConditionVariable.new
@async_scheduler = AsyncScheduler.new(method(:fetch_datafile_config), @polling_interval, auto_update, @logger)
@@ -98,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
@@ -142,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
@@ -222,6 +221,10 @@ def set_config(config)
@notification_center.send_notifications(NotificationCenter::NOTIFICATION_TYPES[:OPTIMIZELY_CONFIG_UPDATE])
+ NotificationCenterRegistry
+ .get_notification_center(@sdk_key, @logger)
+ &.send_notifications(NotificationCenter::NOTIFICATION_TYPES[:OPTIMIZELY_CONFIG_UPDATE])
+
@logger.log(Logger::DEBUG, 'Received new datafile and updated config. ' \
"Old revision number: #{previous_revision}. New revision number: #{@config.revision}.")
end
@@ -260,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/project_config_manager.rb b/lib/optimizely/config_manager/project_config_manager.rb
index e0a3f8e8..220df9ae 100644
--- a/lib/optimizely/config_manager/project_config_manager.rb
+++ b/lib/optimizely/config_manager/project_config_manager.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.
@@ -20,5 +20,6 @@ class ProjectConfigManager
# Interface for fetching ProjectConfig instance.
def config; end
+ def sdk_key; end
end
end
diff --git a/lib/optimizely/config_manager/static_project_config_manager.rb b/lib/optimizely/config_manager/static_project_config_manager.rb
index 281beb3d..200126f8 100644
--- a/lib/optimizely/config_manager/static_project_config_manager.rb
+++ b/lib/optimizely/config_manager/static_project_config_manager.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.
@@ -23,7 +23,7 @@
module Optimizely
class StaticProjectConfigManager < ProjectConfigManager
# Implementation of ProjectConfigManager interface.
- attr_reader :config
+ attr_reader :config, :sdk_key
def initialize(datafile, logger, error_handler, skip_json_validation)
# Looks up and sets datafile and config based on response body.
@@ -41,11 +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/event_dispatcher.rb b/lib/optimizely/event_dispatcher.rb
index aaa0b593..874a43db 100644
--- a/lib/optimizely/event_dispatcher.rb
+++ b/lib/optimizely/event_dispatcher.rb
@@ -17,6 +17,7 @@
#
require_relative 'exceptions'
require_relative 'helpers/http_utils'
+require_relative 'helpers/constants'
module Optimizely
class NoOpEventDispatcher
@@ -26,9 +27,6 @@ def dispatch_event(event); end
end
class EventDispatcher
- # @api constants
- REQUEST_TIMEOUT = 10
-
def initialize(logger: nil, error_handler: nil, proxy_config: nil)
@logger = logger || NoOpLogger.new
@error_handler = error_handler || NoOpErrorHandler.new
@@ -40,7 +38,7 @@ def initialize(logger: nil, error_handler: nil, proxy_config: nil)
# @param event - Event object
def dispatch_event(event)
response = Helpers::HttpUtils.make_request(
- event.url, event.http_verb, event.params.to_json, event.headers, REQUEST_TIMEOUT, @proxy_config
+ event.url, event.http_verb, event.params.to_json, event.headers, Helpers::Constants::EVENT_DISPATCH_CONFIG[:REQUEST_TIMEOUT], @proxy_config
)
error_msg = "Event failed to dispatch with response code: #{response.code}"
diff --git a/lib/optimizely/exceptions.rb b/lib/optimizely/exceptions.rb
index fef0f829..5d608b2f 100644
--- a/lib/optimizely/exceptions.rb
+++ b/lib/optimizely/exceptions.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
#
-# Copyright 2016-2020, 2022, Optimizely and contributors
+# Copyright 2016-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,19 +25,45 @@ def initialize(msg = 'HTTP call resulted in a response with an error code.')
end
end
+ class HTTPUriError < Error
+ # Raised when a provided URI is invalid.
+ def initialize(msg = 'Provided URI was invalid.')
+ super
+ end
+ end
+
+ class MissingSdkKeyError < Error
+ # Raised when a provided URI is invalid.
+ def initialize(msg = 'SDK key not provided/cannot be found in the datafile.')
+ super
+ end
+ end
+
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
@@ -60,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/constants.rb b/lib/optimizely/helpers/constants.rb
index eae4906f..02b815ae 100644
--- a/lib/optimizely/helpers/constants.rb
+++ b/lib/optimizely/helpers/constants.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
#
-# Copyright 2016-2020, Optimizely and contributors
+# Copyright 2016-2020, 2022, 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.
@@ -382,6 +382,15 @@ module Constants
'EVALUATING_AUDIENCES_COMBINED' => "Evaluating audiences for rule '%s': %s."
}.merge(AUDIENCE_EVALUATION_LOGS).freeze
+ ODP_LOGS = {
+ FETCH_SEGMENTS_FAILED: 'Audience segments fetch failed (%s).',
+ ODP_EVENT_FAILED: 'ODP event send failed (%s).',
+ ODP_NOT_ENABLED: 'ODP is not enabled.',
+ ODP_NOT_INTEGRATED: 'ODP is not integrated.',
+ ODP_INVALID_DATA: 'ODP data is not valid.',
+ ODP_INVALID_ACTION: 'ODP action is not valid (cannot be empty).'
+ }.freeze
+
DECISION_NOTIFICATION_TYPES = {
'AB_TEST' => 'ab-test',
'FEATURE' => 'feature',
@@ -406,6 +415,41 @@ module Constants
'REQUEST_TIMEOUT' => 10
}.freeze
+ EVENT_DISPATCH_CONFIG = {
+ REQUEST_TIMEOUT: 10
+ }.freeze
+
+ ODP_GRAPHQL_API_CONFIG = {
+ REQUEST_TIMEOUT: 10
+ }.freeze
+
+ ODP_REST_API_CONFIG = {
+ REQUEST_TIMEOUT: 10
+ }.freeze
+
+ ODP_SEGMENTS_CACHE_CONFIG = {
+ DEFAULT_CAPACITY: 10_000,
+ DEFAULT_TIMEOUT_SECONDS: 600
+ }.freeze
+
+ ODP_MANAGER_CONFIG = {
+ KEY_FOR_USER_ID: 'fs_user_id',
+ EVENT_TYPE: 'fullstack'
+ }.freeze
+
+ ODP_CONFIG_STATE = {
+ UNDETERMINED: 'UNDETERMINED',
+ INTEGRATED: 'INTEGRATED',
+ NOT_INTEGRATED: 'NOT_INTEGRATED'
+ }.freeze
+
+ ODP_EVENT_MANAGER = {
+ DEFAULT_QUEUE_CAPACITY: 10_000,
+ DEFAULT_BATCH_SIZE: 10,
+ DEFAULT_FLUSH_INTERVAL_SECONDS: 1,
+ DEFAULT_RETRY_COUNT: 3
+ }.freeze
+
HTTP_HEADERS = {
'IF_MODIFIED_SINCE' => 'If-Modified-Since',
'LAST_MODIFIED' => 'Last-Modified'
diff --git a/lib/optimizely/helpers/http_utils.rb b/lib/optimizely/helpers/http_utils.rb
index 3530bb6e..f4236e05 100644
--- a/lib/optimizely/helpers/http_utils.rb
+++ b/lib/optimizely/helpers/http_utils.rb
@@ -17,6 +17,7 @@
#
require 'net/http'
+require_relative '../exceptions'
module Optimizely
module Helpers
@@ -28,6 +29,8 @@ def make_request(url, http_method, request_body = nil, headers = {}, read_timeou
#
uri = URI.parse(url)
+ raise HTTPUriError unless uri.respond_to?(:request_uri)
+
case http_method
when :get
request = Net::HTTP::Get.new(uri.request_uri)
diff --git a/lib/optimizely/helpers/sdk_settings.rb b/lib/optimizely/helpers/sdk_settings.rb
new file mode 100644
index 00000000..3ca2dc72
--- /dev/null
+++ b/lib/optimizely/helpers/sdk_settings.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require_relative 'constants'
+
+module Optimizely
+ module Helpers
+ class OptimizelySdkSettings
+ attr_accessor :odp_disabled, :segments_cache_size, :segments_cache_timeout_in_secs, :odp_segments_cache, :odp_segment_manager,
+ :odp_event_manager, :fetch_segments_timeout, :odp_event_timeout, :odp_flush_interval
+
+ # Contains configuration used for Optimizely Project initialization.
+ #
+ # @param disable_odp - Set this flag to true (default = false) to disable ODP features.
+ # @param segments_cache_size - The maximum size of audience segments cache (optional. default = 10,000). Set to zero to disable caching.
+ # @param segments_cache_timeout_in_secs - The timeout in seconds of audience segments cache (optional. default = 600). Set to zero to disable timeout.
+ # @param odp_segments_cache - A custom odp segments cache. Required methods include: `save(key, value)`, `lookup(key) -> value`, and `reset()`
+ # @param odp_segment_manager - A custom odp segment manager. Required method is: `fetch_qualified_segments(user_key, user_value, options)`.
+ # @param odp_event_manager - A custom odp event manager. Required method is: `send_event(type:, action:, identifiers:, data:)`
+ # @param odp_segment_request_timeout - Time to wait in seconds for fetch_qualified_segments (optional. default = 10).
+ # @param odp_event_request_timeout - Time to wait in seconds for send_odp_events (optional. default = 10).
+ # @param odp_event_flush_interval - Time to wait in seconds for odp events to accumulate before sending (optional. default = 1).
+ def initialize(
+ disable_odp: false,
+ segments_cache_size: Constants::ODP_SEGMENTS_CACHE_CONFIG[:DEFAULT_CAPACITY],
+ segments_cache_timeout_in_secs: Constants::ODP_SEGMENTS_CACHE_CONFIG[:DEFAULT_TIMEOUT_SECONDS],
+ odp_segments_cache: nil,
+ odp_segment_manager: nil,
+ odp_event_manager: nil,
+ odp_segment_request_timeout: nil,
+ odp_event_request_timeout: nil,
+ odp_event_flush_interval: nil
+ )
+ @odp_disabled = disable_odp
+ @segments_cache_size = segments_cache_size
+ @segments_cache_timeout_in_secs = segments_cache_timeout_in_secs
+ @odp_segments_cache = odp_segments_cache
+ @odp_segment_manager = odp_segment_manager
+ @odp_event_manager = odp_event_manager
+ @fetch_segments_timeout = odp_segment_request_timeout
+ @odp_event_timeout = odp_event_request_timeout
+ @odp_flush_interval = odp_event_flush_interval
+ end
+ end
+ end
+end
diff --git a/lib/optimizely/helpers/validator.rb b/lib/optimizely/helpers/validator.rb
index fb901f39..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.
@@ -178,6 +178,59 @@ def finite_number?(value)
value.is_a?(Numeric) && value.to_f.finite? && value.abs <= Constants::FINITE_NUMBER_LIMIT
end
+
+ def odp_data_types_valid?(data)
+ valid_types = [String, Float, Integer, TrueClass, FalseClass, NilClass]
+ data&.values&.all? { |e| valid_types.member? e.class }
+ end
+
+ def segments_cache_valid?(segments_cache)
+ # Determines if a given segments_cache is valid.
+ #
+ # segments_cache - custom cache to be validated.
+ #
+ # Returns boolean depending on whether cache has required methods.
+
+ 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)
+ # Determines if a given segment_manager is valid.
+ #
+ # 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?(: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)
+ # Determines if a given event_manager is valid.
+ #
+ # event_manager - custom manager to be validated.
+ #
+ # Returns boolean depending on whether manager has required method and parameters.
+ return false unless
+ event_manager.respond_to?(:send_event) &&
+ event_manager.respond_to?(:start!) &&
+ (event_manager.method(:start!)&.parameters&.length || 0) >= 1 &&
+ event_manager.respond_to?(:update_config) &&
+ event_manager.respond_to?(:stop!)
+
+ required_parameters = Set[%i[keyreq type], %i[keyreq action], %i[keyreq identifiers], %i[keyreq data]]
+ existing_parameters = event_manager.method(:send_event).parameters.to_set
+
+ existing_parameters & required_parameters == required_parameters
+ end
end
end
end
diff --git a/lib/optimizely/notification_center_registry.rb b/lib/optimizely/notification_center_registry.rb
new file mode 100644
index 00000000..aea0ade0
--- /dev/null
+++ b/lib/optimizely/notification_center_registry.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+#
+# Copyright 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+require_relative 'notification_center'
+require_relative 'exceptions'
+
+module Optimizely
+ class NotificationCenterRegistry
+ private_class_method :new
+ # Class managing internal notification centers.
+ # @api no-doc
+ @notification_centers = {}
+ @mutex = Mutex.new
+
+ # Returns an internal notification center for the given sdk_key, creating one
+ # if none exists yet.
+ #
+ # Args:
+ # sdk_key: A string sdk key to uniquely identify the notification center.
+ # logger: Optional logger.
+
+ # Returns:
+ # nil or NotificationCenter
+ def self.get_notification_center(sdk_key, logger)
+ unless sdk_key
+ logger&.log(Logger::ERROR, "#{MissingSdkKeyError.new.message} ODP may not work properly without it.")
+ return nil
+ end
+
+ notification_center = nil
+
+ @mutex.synchronize do
+ if @notification_centers.key?(sdk_key)
+ notification_center = @notification_centers[sdk_key]
+ else
+ notification_center = NotificationCenter.new(logger, nil)
+ @notification_centers[sdk_key] = notification_center
+ end
+ end
+
+ notification_center
+ end
+
+ # Remove a previously added notification center and clear all its listeners.
+
+ # Args:
+ # sdk_key: The sdk_key of the notification center to remove.
+ def self.remove_notification_center(sdk_key)
+ @mutex.synchronize do
+ @notification_centers
+ .delete(sdk_key)
+ &.clear_all_notification_listeners
+ end
+ nil
+ end
+ end
+end
diff --git a/lib/optimizely/odp/lru_cache.rb b/lib/optimizely/odp/lru_cache.rb
new file mode 100644
index 00000000..8ce61549
--- /dev/null
+++ b/lib/optimizely/odp/lru_cache.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+module Optimizely
+ class LRUCache
+ # Least Recently Used cache that invalidates entries older than the timeout.
+
+ attr_reader :capacity, :timeout
+
+ def initialize(capacity, timeout_in_secs)
+ # @param capacity - The max size of the cache. If set <= 0, caching is disabled.
+ # @param timeout_in_secs - Seconds until a cache item is considered stale.
+ # If set <= 0, items never expire.
+ @cache_mutex = Mutex.new
+ @map = {}
+ @capacity = capacity
+ @timeout = timeout_in_secs
+ end
+
+ # Retrieve the non stale value from the cache corresponding to the provided key
+ # or nil if key is not found
+ # Moves the key/value pair to the end of the cache
+ #
+ # @param key - The key to retrieve
+
+ def lookup(key)
+ return nil if @capacity <= 0
+
+ @cache_mutex.synchronize do
+ return nil unless @map.include?(key)
+
+ element = @map.delete(key)
+ return nil if element.stale?(@timeout)
+
+ @map[key] = element
+
+ element.value
+ end
+ end
+
+ # Save a key/value pair into the cache
+ # Moves the key/value pair to the end of the cache
+ #
+ # @param key - A user key
+ # @param value - A user value
+
+ def save(key, value)
+ return if @capacity <= 0
+
+ @cache_mutex.synchronize do
+ @map.delete(key) if @map.key?(key)
+
+ @map[key] = CacheElement.new(value)
+
+ @map.delete(@map.first[0]) if @map.size > @capacity
+ nil
+ end
+ end
+
+ # Clears the cache
+
+ def reset
+ return if @capacity <= 0
+
+ @cache_mutex.synchronize { @map.clear }
+ nil
+ end
+
+ # Retrieve a value from the cache for a given key or nil if key is not found
+ # Doesn't update the cache
+ #
+ # @param key - The key to retrieve
+
+ def peek(key)
+ return nil if @capacity <= 0
+
+ @cache_mutex.synchronize { @map[key]&.value }
+ end
+ end
+
+ class CacheElement
+ # Individual element for the LRUCache.
+ attr_reader :value, :timestamp
+
+ def initialize(value)
+ @value = value
+ @timestamp = Time.new
+ end
+
+ def stale?(timeout)
+ # Returns true if the provided timeout has passed since the element's timestamp.
+ #
+ # @param timeout - The duration to check against
+ return false if timeout <= 0
+
+ Time.new - @timestamp >= timeout
+ end
+ end
+end
diff --git a/lib/optimizely/odp/odp_config.rb b/lib/optimizely/odp/odp_config.rb
new file mode 100644
index 00000000..e425e59f
--- /dev/null
+++ b/lib/optimizely/odp/odp_config.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'optimizely/logger'
+require_relative '../helpers/constants'
+
+module Optimizely
+ class OdpConfig
+ ODP_CONFIG_STATE = Helpers::Constants::ODP_CONFIG_STATE
+ # Contains configuration used for ODP integration.
+ #
+ # @param api_host - The host URL for the ODP audience segments API (optional).
+ # @param api_key - The public API key for the ODP account from which the audience segments will be fetched (optional).
+ # @param segments_to_check - An array of all ODP segments used in the current datafile (associated with api_host/api_key).
+ def initialize(api_key = nil, api_host = nil, segments_to_check = [])
+ @api_key = api_key
+ @api_host = api_host
+ @segments_to_check = segments_to_check
+ @mutex = Mutex.new
+ @odp_state = @api_host.nil? || @api_key.nil? ? ODP_CONFIG_STATE[:UNDETERMINED] : ODP_CONFIG_STATE[:INTEGRATED]
+ end
+
+ # Replaces the existing configuration
+ #
+ # @param api_host - The host URL for the ODP audience segments API (optional).
+ # @param api_key - The public API key for the ODP account from which the audience segments will be fetched (optional).
+ # @param segments_to_check - An array of all ODP segments used in the current datafile (associated with api_host/api_key).
+ #
+ # @return - True if the provided values were different than the existing values.
+
+ def update(api_key = nil, api_host = nil, segments_to_check = [])
+ updated = false
+ @mutex.synchronize do
+ @odp_state = api_host.nil? || api_key.nil? ? ODP_CONFIG_STATE[:NOT_INTEGRATED] : ODP_CONFIG_STATE[:INTEGRATED]
+
+ if @api_key != api_key || @api_host != api_host || @segments_to_check != segments_to_check
+ @api_key = api_key
+ @api_host = api_host
+ @segments_to_check = segments_to_check
+ updated = true
+ end
+ end
+
+ updated
+ end
+
+ # Returns the api host for odp connections
+ #
+ # @return - The api host.
+
+ def api_host
+ @mutex.synchronize { @api_host.clone }
+ end
+
+ # Returns the api key for odp connections
+ #
+ # @return - The api key.
+
+ def api_key
+ @mutex.synchronize { @api_key.clone }
+ end
+
+ # Returns An array of qualified segments for this user
+ #
+ # @return - An array of segments names.
+
+ def segments_to_check
+ @mutex.synchronize { @segments_to_check.clone }
+ end
+
+ # Replace qualified segments with provided segments
+ #
+ # @param segments - An array of segment names
+
+ def segments_to_check=(segments_to_check)
+ @mutex.synchronize { @segments_to_check = segments_to_check.clone }
+ end
+
+ # Returns the state of odp integration (UNDETERMINED, INTEGRATED, NOT_INTEGRATED)
+ #
+ # @return - string
+
+ def odp_state
+ @mutex.synchronize { @odp_state }
+ end
+ end
+end
diff --git a/lib/optimizely/odp/odp_event.rb b/lib/optimizely/odp/odp_event.rb
new file mode 100644
index 00000000..eff559b6
--- /dev/null
+++ b/lib/optimizely/odp/odp_event.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'json'
+require_relative '../helpers/constants'
+
+module Optimizely
+ class OdpEvent
+ # Representation of an odp event which can be sent to the Optimizely odp platform.
+
+ KEY_FOR_USER_ID = Helpers::Constants::ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID]
+
+ def initialize(type:, action:, identifiers:, data:)
+ @type = type
+ @action = action
+ @identifiers = convert_identifiers(identifiers)
+ @data = add_common_event_data(data)
+ end
+
+ def add_common_event_data(custom_data)
+ data = {
+ idempotence_id: SecureRandom.uuid,
+ data_source_type: 'sdk',
+ data_source: 'ruby-sdk',
+ data_source_version: VERSION
+ }
+ data.update(custom_data)
+ data
+ end
+
+ def convert_identifiers(identifiers)
+ # Convert incorrect case/separator of identifier key `fs_user_id`
+ # (ie. `fs-user-id`, `FS_USER_ID`).
+
+ identifiers.clone.each_key do |key|
+ break if key == KEY_FOR_USER_ID
+
+ if ['fs-user-id', KEY_FOR_USER_ID].include?(key.downcase)
+ identifiers[KEY_FOR_USER_ID] = identifiers.delete(key)
+ break
+ end
+ end
+
+ identifiers
+ end
+
+ def to_json(*_args)
+ {
+ type: @type,
+ action: @action,
+ identifiers: @identifiers,
+ data: @data
+ }.to_json
+ end
+
+ def ==(other)
+ to_json == other.to_json
+ end
+ end
+end
diff --git a/lib/optimizely/odp/odp_event_api_manager.rb b/lib/optimizely/odp/odp_event_api_manager.rb
new file mode 100644
index 00000000..cd91129b
--- /dev/null
+++ b/lib/optimizely/odp/odp_event_api_manager.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'json'
+
+module Optimizely
+ class OdpEventApiManager
+ # Interface that handles sending ODP events.
+
+ def initialize(logger: nil, proxy_config: nil, timeout: nil)
+ @logger = logger || NoOpLogger.new
+ @proxy_config = proxy_config
+ @timeout = timeout || Optimizely::Helpers::Constants::ODP_REST_API_CONFIG[:REQUEST_TIMEOUT]
+ end
+
+ # Send events to the ODP Events API.
+ #
+ # @param api_key - public api key
+ # @param api_host - domain url of the host
+ # @param events - array of events to send
+
+ def send_odp_events(api_key, api_host, events)
+ should_retry = false
+ url = "#{api_host}/v3/events"
+
+ headers = {'Content-Type' => 'application/json', 'x-api-key' => api_key.to_s}
+
+ begin
+ response = Helpers::HttpUtils.make_request(
+ url, :post, events.to_json, headers, @timeout, @proxy_config
+ )
+ rescue SocketError, Timeout::Error, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::EFAULT, Errno::ENETUNREACH, Errno::ENETDOWN, Errno::ECONNREFUSED
+ log_failure('network error')
+ should_retry = true
+ return should_retry
+ rescue StandardError => e
+ log_failure(e)
+ return should_retry
+ end
+
+ status = response.code.to_i
+ if status >= 400
+ log_failure(!response.body.empty? ? response.body : "#{status}: #{response.message}")
+ should_retry = status >= 500
+ end
+ should_retry
+ end
+
+ private
+
+ def log_failure(message, level = Logger::ERROR)
+ @logger.log(level, format(Optimizely::Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], message))
+ end
+ end
+end
diff --git a/lib/optimizely/odp/odp_event_manager.rb b/lib/optimizely/odp/odp_event_manager.rb
new file mode 100644
index 00000000..fc9084a1
--- /dev/null
+++ b/lib/optimizely/odp/odp_event_manager.rb
@@ -0,0 +1,286 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2019, 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+require_relative 'odp_event_api_manager'
+require_relative '../helpers/constants'
+require_relative 'odp_event'
+
+module Optimizely
+ class OdpEventManager
+ # Events passed to the OdpEventManager are immediately added to an EventQueue.
+ # The OdpEventManager maintains a single consumer thread that pulls events off of
+ # the BlockingQueue and buffers them for either a configured batch size or for a
+ # maximum duration before the resulting OdpEvent is sent to Odp.
+
+ attr_reader :batch_size, :api_manager, :logger
+ attr_accessor :odp_config
+
+ def initialize(
+ api_manager: nil,
+ logger: NoOpLogger.new,
+ proxy_config: nil,
+ request_timeout: nil,
+ flush_interval: nil
+ )
+ @odp_config = nil
+ @api_host = nil
+ @api_key = nil
+
+ @mutex = Mutex.new
+ @event_queue = SizedQueue.new(Optimizely::Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_QUEUE_CAPACITY])
+ @queue_capacity = Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_QUEUE_CAPACITY]
+ # received signal should be sent after adding item to event_queue
+ @received = ConditionVariable.new
+ @logger = logger
+ @api_manager = api_manager || OdpEventApiManager.new(logger: @logger, proxy_config: proxy_config, timeout: request_timeout)
+ @flush_interval = flush_interval || Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_FLUSH_INTERVAL_SECONDS]
+ @batch_size = @flush_interval&.zero? ? 1 : Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_BATCH_SIZE]
+ @flush_deadline = 0
+ @retry_count = Helpers::Constants::ODP_EVENT_MANAGER[:DEFAULT_RETRY_COUNT]
+ # current_batch should only be accessed by processing thread
+ @current_batch = []
+ @thread = nil
+ @thread_exception = false
+ end
+
+ def start!(odp_config)
+ if running?
+ @logger.log(Logger::WARN, 'Service already started.')
+ return
+ end
+
+ @odp_config = odp_config
+ @api_host = odp_config.api_host
+ @api_key = odp_config.api_key
+
+ @thread = Thread.new { run }
+ @logger.log(Logger::INFO, 'Starting scheduler.')
+ end
+
+ def flush
+ begin
+ @event_queue.push(:FLUSH_SIGNAL, true)
+ rescue ThreadError
+ @logger.log(Logger::ERROR, 'Error flushing ODP event queue.')
+ return
+ end
+
+ @mutex.synchronize do
+ @received.signal
+ end
+ end
+
+ def update_config
+ begin
+ # Adds update config signal to event_queue.
+ @event_queue.push(:UPDATE_CONFIG, true)
+ rescue ThreadError
+ @logger.log(Logger::ERROR, 'Error updating ODP config for the event queue')
+ end
+
+ @mutex.synchronize do
+ @received.signal
+ end
+ end
+
+ def dispatch(event)
+ if @thread_exception
+ @logger.log(Logger::ERROR, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], 'Queue is down'))
+ return
+ end
+
+ # if the processor has been explicitly stopped. Don't accept tasks
+ unless running?
+ @logger.log(Logger::WARN, 'ODP event queue is shutdown, not accepting events.')
+ return
+ end
+
+ begin
+ @logger.log(Logger::DEBUG, 'ODP event queue: adding event.')
+ @event_queue.push(event, true)
+ rescue => e
+ @logger.log(Logger::WARN, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], e.message))
+ return
+ end
+
+ @mutex.synchronize do
+ @received.signal
+ end
+ end
+
+ def send_event(type:, action:, identifiers:, data:)
+ case @odp_config&.odp_state
+ when nil
+ @logger.log(Logger::DEBUG, 'ODP event queue: cannot send before config has been set.')
+ return
+ when OdpConfig::ODP_CONFIG_STATE[:UNDETERMINED]
+ @logger.log(Logger::DEBUG, 'ODP event queue: cannot send before the datafile has loaded.')
+ return
+ when OdpConfig::ODP_CONFIG_STATE[:NOT_INTEGRATED]
+ @logger.log(Logger::DEBUG, Helpers::Constants::ODP_LOGS[:ODP_NOT_INTEGRATED])
+ return
+ end
+
+ event = Optimizely::OdpEvent.new(type: type, action: action, identifiers: identifiers, data: data)
+ dispatch(event)
+ end
+
+ def stop!
+ return unless running?
+
+ begin
+ @event_queue.push(:SHUTDOWN_SIGNAL, true)
+ rescue ThreadError
+ @logger.log(Logger::ERROR, 'Error stopping ODP event queue.')
+ return
+ end
+
+ @event_queue.close
+
+ @mutex.synchronize do
+ @received.signal
+ end
+
+ @logger.log(Logger::INFO, 'Stopping ODP event queue.')
+
+ @thread.join
+
+ @logger.log(Logger::ERROR, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], @current_batch.to_json)) unless @current_batch.empty?
+ end
+
+ def running?
+ !!@thread && !!@thread.status && !@event_queue.closed?
+ end
+
+ private
+
+ def run
+ loop do
+ @mutex.synchronize do
+ @received.wait(@mutex, queue_timeout) if @event_queue.empty?
+ end
+
+ begin
+ item = @event_queue.pop(true)
+ rescue ThreadError => e
+ raise unless e.message == 'queue empty'
+
+ item = nil
+ end
+
+ case item
+ when :SHUTDOWN_SIGNAL
+ @logger.log(Logger::DEBUG, 'ODP event queue: received shutdown signal.')
+ break
+
+ when :FLUSH_SIGNAL
+ @logger.log(Logger::DEBUG, 'ODP event queue: received flush signal.')
+ flush_batch!
+
+ when :UPDATE_CONFIG
+ @logger.log(Logger::DEBUG, 'ODP event queue: received update config signal.')
+ process_config_update
+
+ when Optimizely::OdpEvent
+ add_to_batch(item)
+
+ when nil && !@current_batch.empty?
+ @logger.log(Logger::DEBUG, 'ODP event queue: flushing on interval.')
+ flush_batch!
+ end
+ end
+ rescue SignalException
+ @thread_exception = true
+ @logger.log(Logger::ERROR, 'Interrupted while processing ODP events.')
+ rescue => e
+ @thread_exception = true
+ @logger.log(Logger::ERROR, "Uncaught exception processing ODP events. Error: #{e.message}")
+ ensure
+ @logger.log(Logger::INFO, 'Exiting ODP processing loop. Attempting to flush pending events.')
+ flush_batch!
+ end
+
+ def flush_batch!
+ if @current_batch.empty?
+ @logger.log(Logger::DEBUG, 'ODP event queue: nothing to flush.')
+ return
+ end
+
+ if @api_key.nil? || @api_host.nil?
+ @logger.log(Logger::DEBUG, Helpers::Constants::ODP_LOGS[:ODP_NOT_INTEGRATED])
+ @current_batch.clear
+ return
+ end
+
+ @logger.log(Logger::DEBUG, "ODP event queue: flushing batch size #{@current_batch.length}.")
+ should_retry = false
+
+ i = 0
+ while i < @retry_count
+ begin
+ should_retry = @api_manager.send_odp_events(@api_key, @api_host, @current_batch)
+ rescue StandardError => e
+ should_retry = false
+ @logger.log(Logger::ERROR, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], "Error: #{e.message} #{@current_batch.to_json}"))
+ end
+ break unless should_retry
+
+ @logger.log(Logger::DEBUG, 'Error dispatching ODP events, scheduled to retry.') if i < @retry_count
+ i += 1
+ end
+
+ @logger.log(Logger::ERROR, format(Helpers::Constants::ODP_LOGS[:ODP_EVENT_FAILED], "Failed after #{i} retries: #{@current_batch.to_json}")) if should_retry
+
+ @current_batch.clear
+ end
+
+ def add_to_batch(event)
+ set_flush_deadline if @current_batch.empty?
+
+ @current_batch << event
+ return unless @current_batch.length >= @batch_size
+
+ @logger.log(Logger::DEBUG, 'ODP event queue: flushing on batch size.')
+ flush_batch!
+ end
+
+ def set_flush_deadline
+ # Sets time that next flush will occur.
+ @flush_deadline = Time.new + @flush_interval
+ end
+
+ def time_till_flush
+ # Returns seconds until next flush; no less than 0.
+ [0, @flush_deadline - Time.new].max
+ end
+
+ def queue_timeout
+ # Returns seconds until next flush or None if current batch is empty.
+ return nil if @current_batch.empty?
+
+ time_till_flush
+ end
+
+ def process_config_update
+ # Updates the configuration used to send events.
+ flush_batch! unless @current_batch.empty?
+
+ @api_key = @odp_config&.api_key
+ @api_host = @odp_config&.api_host
+ end
+ end
+end
diff --git a/lib/optimizely/odp/odp_manager.rb b/lib/optimizely/odp/odp_manager.rb
new file mode 100644
index 00000000..77e35035
--- /dev/null
+++ b/lib/optimizely/odp/odp_manager.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'optimizely/logger'
+require_relative '../helpers/constants'
+require_relative '../helpers/validator'
+require_relative '../exceptions'
+require_relative 'odp_config'
+require_relative 'lru_cache'
+require_relative 'odp_segment_manager'
+require_relative 'odp_event_manager'
+
+module Optimizely
+ class OdpManager
+ ODP_LOGS = Helpers::Constants::ODP_LOGS
+ ODP_MANAGER_CONFIG = Helpers::Constants::ODP_MANAGER_CONFIG
+ ODP_CONFIG_STATE = Helpers::Constants::ODP_CONFIG_STATE
+
+ # update_odp_config must be called to complete initialization
+ def initialize(
+ disable:,
+ segments_cache: nil,
+ segment_manager: nil,
+ event_manager: nil,
+ fetch_segments_timeout: nil,
+ odp_event_timeout: nil,
+ odp_flush_interval: nil,
+ logger: nil
+ )
+ @enabled = !disable
+ @segment_manager = segment_manager
+ @event_manager = event_manager
+ @logger = logger || NoOpLogger.new
+ @odp_config = OdpConfig.new
+
+ unless @enabled
+ @logger.log(Logger::INFO, ODP_LOGS[:ODP_NOT_ENABLED])
+ return
+ end
+
+ unless @segment_manager
+ segments_cache ||= LRUCache.new(
+ Helpers::Constants::ODP_SEGMENTS_CACHE_CONFIG[:DEFAULT_CAPACITY],
+ Helpers::Constants::ODP_SEGMENTS_CACHE_CONFIG[:DEFAULT_TIMEOUT_SECONDS]
+ )
+ @segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, @logger, timeout: fetch_segments_timeout)
+ end
+
+ @event_manager ||= Optimizely::OdpEventManager.new(logger: @logger, request_timeout: odp_event_timeout, flush_interval: odp_flush_interval)
+
+ @segment_manager.odp_config = @odp_config
+ end
+
+ def fetch_qualified_segments(user_id:, options:)
+ # Returns qualified segments for the user from the cache or the ODP server if not in the cache.
+ #
+ # @param user_id - The user id.
+ # @param options - An array of OptimizelySegmentOptions used to ignore and/or reset the cache.
+ #
+ # @return - Array of qualified segments or nil.
+ options ||= []
+ unless @enabled
+ @logger.log(Logger::ERROR, ODP_LOGS[:ODP_NOT_ENABLED])
+ return nil
+ end
+
+ if @odp_config.odp_state == ODP_CONFIG_STATE[:UNDETERMINED]
+ @logger.log(Logger::ERROR, 'Cannot fetch segments before the datafile has loaded.')
+ return nil
+ end
+
+ @segment_manager.fetch_qualified_segments(ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID], user_id, options)
+ end
+
+ def identify_user(user_id:)
+ unless @enabled
+ @logger.log(Logger::DEBUG, 'ODP identify event is not dispatched (ODP disabled).')
+ return
+ end
+
+ case @odp_config.odp_state
+ when ODP_CONFIG_STATE[:UNDETERMINED]
+ @logger.log(Logger::DEBUG, 'ODP identify event is not dispatched (datafile not ready).')
+ return
+ when ODP_CONFIG_STATE[:NOT_INTEGRATED]
+ @logger.log(Logger::DEBUG, 'ODP identify event is not dispatched (ODP not integrated).')
+ return
+ end
+
+ @event_manager.send_event(
+ type: ODP_MANAGER_CONFIG[:EVENT_TYPE],
+ action: 'identified',
+ identifiers: {ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID] => user_id},
+ data: {}
+ )
+ end
+
+ def send_event(type:, action:, identifiers:, data:)
+ # Send an event to the ODP server.
+ #
+ # @param type - the event type.
+ # @param action - the event action name.
+ # @param identifiers - a hash for identifiers.
+ # @param data - a hash for associated data. The default event data will be added to this data before sending to the ODP server.
+ unless @enabled
+ @logger.log(Logger::ERROR, ODP_LOGS[:ODP_NOT_ENABLED])
+ return
+ end
+
+ unless Helpers::Validator.odp_data_types_valid?(data)
+ @logger.log(Logger::ERROR, ODP_LOGS[:ODP_INVALID_DATA])
+ return
+ end
+
+ @event_manager.send_event(type: type, action: action, identifiers: identifiers, data: data)
+ end
+
+ def update_odp_config(api_key, api_host, segments_to_check)
+ # Update the odp config, reset the cache and send signal to the event processor to update its config.
+ # Start the event manager if odp is integrated.
+ return unless @enabled
+
+ config_changed = @odp_config.update(api_key, api_host, segments_to_check)
+ unless config_changed
+ @logger.log(Logger::DEBUG, 'Odp config was not changed.')
+ return
+ end
+
+ @segment_manager.reset
+
+ if @event_manager.running?
+ @event_manager.update_config
+ elsif @odp_config.odp_state == ODP_CONFIG_STATE[:INTEGRATED]
+ @event_manager.start!(@odp_config)
+ end
+ end
+
+ def stop!
+ return unless @enabled
+
+ @event_manager.stop!
+ end
+ end
+end
diff --git a/lib/optimizely/odp/odp_segment_api_manager.rb b/lib/optimizely/odp/odp_segment_api_manager.rb
new file mode 100644
index 00000000..289bc49f
--- /dev/null
+++ b/lib/optimizely/odp/odp_segment_api_manager.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'json'
+require_relative '../exceptions'
+
+module Optimizely
+ class OdpSegmentApiManager
+ # Interface that handles fetching audience segments.
+
+ def initialize(logger: nil, proxy_config: nil, timeout: nil)
+ @logger = logger || NoOpLogger.new
+ @proxy_config = proxy_config
+ @timeout = timeout || Optimizely::Helpers::Constants::ODP_GRAPHQL_API_CONFIG[:REQUEST_TIMEOUT]
+ end
+
+ # Fetch segments from the ODP GraphQL API.
+ #
+ # @param api_key - public api key
+ # @param api_host - domain url of the host
+ # @param user_key - vuid or fs_user_id (client device id or fullstack id)
+ # @param user_value - value of user_key
+ # @param segments_to_check - array of segments to check
+
+ def fetch_segments(api_key, api_host, user_key, user_value, segments_to_check)
+ url = "#{api_host}/v3/graphql"
+
+ headers = {'Content-Type' => 'application/json', 'x-api-key' => api_key.to_s}
+
+ payload = {
+ query: 'query($userId: String, $audiences: [String]) {' \
+ "customer(#{user_key}: $userId) " \
+ '{audiences(subset: $audiences) {edges {node {name state}}}}}',
+ variables: {
+ userId: user_value.to_s,
+ audiences: segments_to_check || []
+ }
+ }.to_json
+
+ begin
+ response = Helpers::HttpUtils.make_request(
+ url, :post, payload, headers, @timeout, @proxy_config
+ )
+ rescue SocketError, Timeout::Error, Net::ProtocolError, Errno::ECONNRESET => e
+ @logger.log(Logger::DEBUG, "GraphQL download failed: #{e}")
+ log_segments_failure('network error')
+ return nil
+ rescue Errno::EINVAL, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, HTTPUriError => e
+ log_segments_failure(e)
+ return nil
+ end
+
+ status = response.code.to_i
+ if status >= 400
+ log_segments_failure(status)
+ return nil
+ end
+
+ begin
+ response = JSON.parse(response.body)
+ rescue JSON::ParserError
+ log_segments_failure('JSON decode error')
+ return nil
+ end
+
+ if response.include?('errors')
+ error = response['errors'].first if response['errors'].is_a? Array
+ error_code = extract_component(error, 'extensions', 'code')
+ if error_code == 'INVALID_IDENTIFIER_EXCEPTION'
+ log_segments_failure('invalid identifier', Logger::WARN)
+ else
+ error_class = extract_component(error, 'extensions', 'classification') || 'decode error'
+ log_segments_failure(error_class)
+ end
+ return nil
+ end
+
+ audiences = extract_component(response, 'data', 'customer', 'audiences', 'edges')
+ unless audiences
+ log_segments_failure('decode error')
+ return nil
+ end
+
+ audiences.filter_map do |edge|
+ name = extract_component(edge, 'node', 'name')
+ state = extract_component(edge, 'node', 'state')
+ unless name && state
+ log_segments_failure('decode error')
+ return nil
+ end
+ state == 'qualified' ? name : nil
+ end
+ end
+
+ private
+
+ def log_segments_failure(message, level = Logger::ERROR)
+ @logger.log(level, format(Optimizely::Helpers::Constants::ODP_LOGS[:FETCH_SEGMENTS_FAILED], message))
+ end
+
+ def extract_component(hash, *components)
+ hash.dig(*components) if hash.is_a? Hash
+ rescue TypeError
+ nil
+ end
+ end
+end
diff --git a/lib/optimizely/odp/odp_segment_manager.rb b/lib/optimizely/odp/odp_segment_manager.rb
new file mode 100644
index 00000000..063a72e5
--- /dev/null
+++ b/lib/optimizely/odp/odp_segment_manager.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'optimizely/logger'
+require_relative 'odp_segment_api_manager'
+
+module Optimizely
+ class OdpSegmentManager
+ # Schedules connections to ODP for audience segmentation and caches the results
+ attr_accessor :odp_config
+ attr_reader :segments_cache, :api_manager, :logger
+
+ def initialize(segments_cache, api_manager = nil, logger = nil, proxy_config = nil, timeout: nil)
+ @odp_config = nil
+ @logger = logger || NoOpLogger.new
+ @api_manager = api_manager || OdpSegmentApiManager.new(logger: @logger, proxy_config: proxy_config, timeout: timeout)
+ @segments_cache = segments_cache
+ end
+
+ # Returns qualified segments for the user from the cache or the ODP server if not in the cache.
+ #
+ # @param user_key - The key for identifying the id type.
+ # @param user_value - The id itself.
+ # @param options - An array of OptimizelySegmentOptions used to ignore and/or reset the cache.
+ #
+ # @return - Array of qualified segments.
+ def fetch_qualified_segments(user_key, user_value, options)
+ odp_api_key = @odp_config&.api_key
+ odp_api_host = @odp_config&.api_host
+ segments_to_check = @odp_config&.segments_to_check
+
+ if odp_api_key.nil? || odp_api_host.nil?
+ @logger.log(Logger::ERROR, format(Optimizely::Helpers::Constants::ODP_LOGS[:FETCH_SEGMENTS_FAILED], 'ODP is not enabled'))
+ return nil
+ end
+
+ unless segments_to_check&.size&.positive?
+ @logger.log(Logger::DEBUG, 'No segments are used in the project. Returning empty list')
+ return []
+ end
+
+ cache_key = make_cache_key(user_key, user_value)
+
+ ignore_cache = options.include?(OptimizelySegmentOption::IGNORE_CACHE)
+ reset_cache = options.include?(OptimizelySegmentOption::RESET_CACHE)
+
+ reset if reset_cache
+
+ unless ignore_cache || reset_cache
+ segments = @segments_cache.lookup(cache_key)
+ unless segments.nil?
+ @logger.log(Logger::DEBUG, 'ODP cache hit. Returning segments from cache.')
+ return segments
+ end
+ @logger.log(Logger::DEBUG, 'ODP cache miss.')
+ end
+
+ @logger.log(Logger::DEBUG, 'Making a call to ODP server.')
+
+ segments = @api_manager.fetch_segments(odp_api_key, odp_api_host, user_key, user_value, segments_to_check)
+ @segments_cache.save(cache_key, segments) unless segments.nil? || ignore_cache
+ segments
+ end
+
+ def reset
+ @segments_cache.reset
+ nil
+ end
+
+ private
+
+ def make_cache_key(user_key, user_value)
+ "#{user_key}-$-#{user_value}"
+ end
+ end
+
+ class OptimizelySegmentOption
+ # Options for the OdpSegmentManager
+ IGNORE_CACHE = :IGNORE_CACHE
+ RESET_CACHE = :RESET_CACHE
+ end
+end
diff --git a/lib/optimizely/optimizely_config.rb b/lib/optimizely/optimizely_config.rb
index 7b53a07c..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
@@ -201,7 +203,7 @@ def lookup_name_from_id(audience_id, audiences_map)
def stringify_conditions(conditions, audiences_map)
operand = 'OR'
conditions_str = ''
- length = conditions.length()
+ length = conditions.length
return '' if length.zero?
return "\"#{lookup_name_from_id(conditions[0], audiences_map)}\"" if length == 1 && !OPERATORS.include?(conditions[0])
diff --git a/lib/optimizely/optimizely_factory.rb b/lib/optimizely/optimizely_factory.rb
index 99ea733f..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.
@@ -126,6 +126,7 @@ def self.default_instance_with_config_manager(config_manager)
# @param user_profile_service - Optional UserProfileServiceInterface Provides methods to store and retreive user profiles.
# @param config_manager - Optional ConfigManagerInterface Responds to 'config' method.
# @param notification_center - Optional Instance of NotificationCenter.
+ # @param settings: Optional instance of OptimizelySdkSettings for sdk configuration.
#
# if @max_event_batch_size and @max_event_flush_interval are nil then default batchsize and flush_interval
# will be used to setup batchEventProcessor.
@@ -138,9 +139,9 @@ def self.custom_instance( # rubocop:disable Metrics/ParameterLists
skip_json_validation = false, # rubocop:disable Style/OptionalBooleanParameter
user_profile_service = nil,
config_manager = nil,
- notification_center = nil
+ 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)
@@ -165,16 +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
+ 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/optimizely_user_context.rb b/lib/optimizely/optimizely_user_context.rb
index 1298fb7d..9594c591 100644
--- a/lib/optimizely/optimizely_user_context.rb
+++ b/lib/optimizely/optimizely_user_context.rb
@@ -26,7 +26,7 @@ class OptimizelyUserContext
OptimizelyDecisionContext = Struct.new(:flag_key, :rule_key)
OptimizelyForcedDecision = Struct.new(:variation_key)
- def initialize(optimizely_client, user_id, user_attributes)
+ def initialize(optimizely_client, user_id, user_attributes, identify: true)
@attr_mutex = Mutex.new
@forced_decision_mutex = Mutex.new
@qualified_segment_mutex = Mutex.new
@@ -34,13 +34,15 @@ def initialize(optimizely_client, user_id, user_attributes)
@user_id = user_id
@user_attributes = user_attributes.nil? ? {} : user_attributes.clone
@forced_decisions = {}
- @qualified_segments = []
+ @qualified_segments = nil
+
+ @optimizely_client&.identify_user(user_id: user_id) if identify
end
def clone
- user_context = OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes)
+ user_context = OptimizelyUserContext.new(@optimizely_client, @user_id, user_attributes, identify: false)
@forced_decision_mutex.synchronize { user_context.instance_variable_set('@forced_decisions', @forced_decisions.dup) unless @forced_decisions.empty? }
- @qualified_segment_mutex.synchronize { user_context.instance_variable_set('@qualified_segments', @qualified_segments.dup) unless @qualified_segments.empty? }
+ @qualified_segment_mutex.synchronize { user_context.instance_variable_set('@qualified_segments', @qualified_segments.dup) unless @qualified_segments.nil? }
user_context
end
@@ -194,11 +196,43 @@ def qualified_segments=(segments)
# Checks if user is qualified for the provided segment.
#
# @param segment - A segment name
+ # @return true if qualified.
def qualified_for?(segment)
- return false if @qualified_segments.empty?
+ qualified = false
+ @qualified_segment_mutex.synchronize do
+ break if @qualified_segments.nil? || @qualified_segments.empty?
+
+ qualified = @qualified_segments.include?(segment)
+ end
+ qualified
+ end
+
+ # Fetch all qualified segments for the user context.
+ #
+ # The segments fetched will be saved in `@qualified_segments` and can be accessed any time.
+ #
+ # @param options - A set of options for fetching qualified segments (optional).
+ # @param block - An optional block to call after segments have been fetched.
+ # If a block is provided, segments will be fetched on a separate thread.
+ # Block will be called with a boolean indicating if the fetch succeeded.
+ # @return If no block is provided, a boolean indicating whether the fetch was successful.
+ # Otherwise, returns a thread handle and the status boolean is passed to the block.
- @qualified_segment_mutex.synchronize { @qualified_segments.include?(segment) }
+ def fetch_qualified_segments(options: [], &block)
+ fetch_segments = lambda do |opts, callback|
+ segments = @optimizely_client&.fetch_qualified_segments(user_id: @user_id, options: opts)
+ self.qualified_segments = segments
+ success = !segments.nil?
+ callback&.call(success)
+ success
+ end
+
+ if block_given?
+ Thread.new(options, block, &fetch_segments)
+ else
+ fetch_segments.call(options, nil)
+ end
end
end
end
diff --git a/lib/optimizely/user_condition_evaluator.rb b/lib/optimizely/user_condition_evaluator.rb
index ced8ebf7..af701616 100644
--- a/lib/optimizely/user_condition_evaluator.rb
+++ b/lib/optimizely/user_condition_evaluator.rb
@@ -331,7 +331,7 @@ def semver_less_than_or_equal_evaluator(condition)
end
def qualified_evaluator(condition)
- # Evaluate the given match condition for the given user qaulified segments.
+ # Evaluate the given match condition for the given user qualified segments.
# Returns boolean true if condition value is in the user's qualified segments,
# false if the condition value is not in the user's qualified segments,
# nil if the condition value isn't a string.
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 81851e7d..27894065 100644
--- a/lib/optimizely/version.rb
+++ b/lib/optimizely/version.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
#
-# Copyright 2016-2019, 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 = '4.0.0'
+ VERSION = '5.1.0'
end
diff --git a/optimizely-sdk.gemspec b/optimizely-sdk.gemspec
index dd90d70b..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 Optimizely's Full Stack product."
- 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 a531d8f9..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,8 +25,9 @@
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 }
it 'should return true for user_meets_audience_conditions? when experiment is using no audience' do
# Both Audience Ids and Conditions are Empty
@@ -46,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/config_manager/http_project_config_manager_spec.rb b/spec/config_manager/http_project_config_manager_spec.rb
index c786f38e..3c048e9f 100644
--- a/spec/config_manager/http_project_config_manager_spec.rb
+++ b/spec/config_manager/http_project_config_manager_spec.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.
@@ -57,6 +57,7 @@
describe '.project_config_manager' do
it 'should get project config when valid url is given' do
@http_project_config_manager = Optimizely::HTTPProjectConfigManager.new(
+ sdk_key: 'valid_sdk_key',
url: 'https://cdn.optimizely.com/datafiles/valid_sdk_key.json'
)
@@ -75,6 +76,7 @@
.to_return(status: 200, body: VALID_SDK_KEY_CONFIG_JSON, headers: {})
@http_project_config_manager = Optimizely::HTTPProjectConfigManager.new(
+ sdk_key: 'valid_sdk_key',
url: 'http://cdn.optimizely.com/datafiles/valid_sdk_key.json'
)
diff --git a/spec/decision_service_spec.rb b/spec/decision_service_spec.rb
index 3d4a687f..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,8 +28,9 @@
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 }
describe '#get_variation' do
before(:example) do
@@ -72,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([
@@ -89,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.",
@@ -188,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\"}]]].",
@@ -239,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.",
@@ -258,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.",
@@ -310,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: {
@@ -329,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."
@@ -345,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: {
@@ -359,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.",
@@ -368,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
@@ -398,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.",
@@ -408,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'."
])
@@ -439,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.",
@@ -487,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
@@ -496,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."])
@@ -516,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
@@ -525,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'."])
@@ -559,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
@@ -585,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'."])
@@ -618,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'
@@ -815,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
@@ -889,7 +807,8 @@
bucketing_id, reason = decision_service.send(:get_bucketing_id, 'test_user', user_attributes)
expect(bucketing_id).to eq('test_user')
expect(reason).to eq(nil)
- expect(spy_logger).not_to have_received(:log)
+ expect(spy_logger).not_to have_received(:log).with(Logger::WARN, anything)
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
end
it 'should not log any message and return given bucketing ID when bucketing ID is a String' do
@@ -900,7 +819,8 @@
bucketing_id, reason = decision_service.send(:get_bucketing_id, 'test_user', user_attributes)
expect(bucketing_id).to eq('i_am_bucketing_id')
expect(reason).to eq(nil)
- expect(spy_logger).not_to have_received(:log)
+ expect(spy_logger).not_to have_received(:log).with(Logger::WARN, anything)
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
end
it 'should not log any message and return empty String when bucketing ID is empty String' do
@@ -911,7 +831,8 @@
bucketing_id, reason = decision_service.send(:get_bucketing_id, 'test_user', user_attributes)
expect(bucketing_id).to eq('')
expect(reason).to eq(nil)
- expect(spy_logger).not_to have_received(:log)
+ expect(spy_logger).not_to have_received(:log).with(Logger::WARN, anything)
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
end
end
diff --git a/spec/event_builder_spec.rb b/spec/event_builder_spec.rb
index 3f19ac73..4201c579 100644
--- a/spec/event_builder_spec.rb
+++ b/spec/event_builder_spec.rb
@@ -27,10 +27,10 @@
@config_body = OptimizelySpec::VALID_CONFIG_BODY
@config_body_json = OptimizelySpec::VALID_CONFIG_BODY_JSON
@error_handler = Optimizely::NoOpErrorHandler.new
- @logger = Optimizely::SimpleLogger.new
end
before(:example) do
+ @logger = spy('logger')
config = Optimizely::DatafileProjectConfig.new(@config_body_json, @logger, @error_handler)
@event_builder = Optimizely::EventBuilder.new(@logger)
@event = config.get_event_from_key('test_event')
diff --git a/spec/event_dispatcher_spec.rb b/spec/event_dispatcher_spec.rb
index 499e8f09..193f584d 100644
--- a/spec/event_dispatcher_spec.rb
+++ b/spec/event_dispatcher_spec.rb
@@ -52,7 +52,7 @@
event.http_verb,
event.params.to_json,
event.headers,
- Optimizely::EventDispatcher::REQUEST_TIMEOUT,
+ Optimizely::Helpers::Constants::EVENT_DISPATCH_CONFIG[:REQUEST_TIMEOUT],
proxy_config
)
diff --git a/spec/notification_center_registry_spec.rb b/spec/notification_center_registry_spec.rb
new file mode 100644
index 00000000..2a4521c7
--- /dev/null
+++ b/spec/notification_center_registry_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2017-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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+require 'spec_helper'
+require 'optimizely/error_handler'
+require 'optimizely/event_builder'
+require 'optimizely/exceptions'
+require 'optimizely/logger'
+require 'optimizely/notification_center'
+require 'optimizely/notification_center_registry'
+describe Optimizely::NotificationCenter do
+ let(:spy_logger) { spy('logger') }
+ let(:config_body) { OptimizelySpec::VALID_CONFIG_BODY }
+ let(:config_body_JSON) { OptimizelySpec::VALID_CONFIG_BODY_JSON }
+ let(:error_handler) { Optimizely::NoOpErrorHandler.new }
+ let(:logger) { Optimizely::NoOpLogger.new }
+ let(:notification_center) { Optimizely::NotificationCenter.new(spy_logger, error_handler) }
+
+ describe '#NotificationCenterRegistry' do
+ describe 'test get notification center' do
+ it 'should log error with no sdk_key' do
+ Optimizely::NotificationCenterRegistry.get_notification_center(nil, spy_logger)
+ expect(spy_logger).to have_received(:log).with(Logger::ERROR, "#{Optimizely::MissingSdkKeyError.new.message} ODP may not work properly without it.")
+ end
+
+ it 'should return notification center with odp callback' do
+ sdk_key = 'VALID_KEY'
+ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json")
+ .to_return(status: 200, body: config_body_JSON)
+
+ 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
+
+ config_notifications = notification_center.instance_variable_get('@notifications')[Optimizely::NotificationCenter::NOTIFICATION_TYPES[:OPTIMIZELY_CONFIG_UPDATE]]
+ expect(config_notifications).to include({notification_id: anything, callback: project.method(:update_odp_config_on_datafile_update)})
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+
+ project.close
+ end
+
+ it 'should only create one notification center per sdk_key' do
+ sdk_key = 'single'
+ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json")
+ .to_return(status: 200, body: config_body_JSON)
+
+ notification_center = Optimizely::NotificationCenterRegistry.get_notification_center(sdk_key, spy_logger)
+ 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)
+
+ project.close
+ end
+ end
+
+ describe 'test remove notification center' do
+ it 'should remove notification center and callbacks' do
+ sdk_key = 'segments-test'
+ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json")
+ .to_return(status: 200, body: config_body_JSON)
+
+ notification_center = Optimizely::NotificationCenterRegistry.get_notification_center(sdk_key, spy_logger)
+ expect(notification_center).to receive(:send_notifications).once
+
+ project = Optimizely::Project.new(logger: spy_logger, sdk_key: sdk_key)
+ project.config_manager.config
+
+ Optimizely::NotificationCenterRegistry.remove_notification_center(sdk_key)
+ expect(Optimizely::NotificationCenterRegistry.instance_variable_get('@notification_centers').values).not_to include(notification_center)
+
+ revised_datafile = config_body.dup
+ revised_datafile['revision'] = (revised_datafile['revision'].to_i + 1).to_s
+ revised_datafile = Optimizely::DatafileProjectConfig.create(JSON.dump(revised_datafile), spy_logger, nil, nil)
+
+ # trigger notification
+ project.config_manager.send(:set_config, revised_datafile)
+ expect(notification_center).not_to receive(:send_notifications)
+ expect(notification_center).not_to eq(Optimizely::NotificationCenterRegistry.get_notification_center(sdk_key, spy_logger))
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+
+ project.close
+ end
+ end
+ end
+end
diff --git a/spec/notification_center_spec.rb b/spec/notification_center_spec.rb
index 978de5ac..7ac4e808 100644
--- a/spec/notification_center_spec.rb
+++ b/spec/notification_center_spec.rb
@@ -313,7 +313,7 @@ def call; end
notification_type = Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE]
@inner_notification_center.clear_notification_listeners(notification_type)
expect { @inner_notification_center.clear_notification_listeners(notification_type) }
- .to_not raise_error(Optimizely::InvalidNotificationType)
+ .to_not raise_error
expect(
@inner_notification_center.notifications[
Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE]
diff --git a/spec/odp/lru_cache_spec.rb b/spec/odp/lru_cache_spec.rb
new file mode 100644
index 00000000..46363c8b
--- /dev/null
+++ b/spec/odp/lru_cache_spec.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+require 'optimizely/odp/lru_cache'
+
+describe Optimizely::LRUCache do
+ it 'should create a cache with min config' do
+ cache = Optimizely::LRUCache.new(1000, 2000)
+ expect(cache.capacity).to eq 1000
+ expect(cache.timeout).to eq 2000
+
+ cache = Optimizely::LRUCache.new(0, 0)
+ expect(cache.capacity).to eq 0
+ expect(cache.timeout).to eq 0
+ end
+
+ it 'should save and lookup correctly' do
+ max_size = 2
+ cache = Optimizely::LRUCache.new(max_size, 1000)
+
+ expect(cache.peek(1)).to be_nil
+ cache.save(1, 100) # [1]
+ cache.save(2, 200) # [1, 2]
+ cache.save(3, 300) # [2, 3]
+ expect(cache.peek(1)).to be_nil
+ expect(cache.peek(2)).to be 200
+ expect(cache.peek(3)).to be 300
+
+ cache.save(2, 201) # [3, 2]
+ cache.save(1, 101) # [2, 1]
+ expect(cache.peek(1)).to eq 101
+ expect(cache.peek(2)).to eq 201
+ expect(cache.peek(3)).to be_nil
+
+ expect(cache.lookup(3)).to be_nil # [2, 1]
+ expect(cache.lookup(2)).to eq 201 # [1, 2]
+ cache.save(3, 302) # [2, 3]
+ expect(cache.peek(1)).to be_nil
+ expect(cache.peek(2)).to eq 201
+ expect(cache.peek(3)).to eq 302
+
+ expect(cache.lookup(3)).to eq 302 # [2, 3]
+ cache.save(1, 103) # [3, 1]
+ expect(cache.peek(1)).to eq 103
+ expect(cache.peek(2)).to be_nil
+ expect(cache.peek(3)).to eq 302
+
+ expect(cache.instance_variable_get('@map').size).to be max_size
+ expect(cache.instance_variable_get('@map').size).to be cache.capacity
+ end
+
+ it 'should disable cache with size zero' do
+ cache = Optimizely::LRUCache.new(0, 1000)
+
+ expect(cache.lookup(1)).to be_nil
+ cache.save(1, 100) # [1]
+ expect(cache.lookup(1)).to be_nil
+ end
+
+ it 'should disable with cache size less than zero' do
+ cache = Optimizely::LRUCache.new(-2, 1000)
+
+ expect(cache.lookup(1)).to be_nil
+ cache.save(1, 100) # [1]
+ expect(cache.lookup(1)).to be_nil
+ end
+
+ it 'should make elements stale after timeout' do
+ max_timeout = 0.5
+
+ cache = Optimizely::LRUCache.new(1000, max_timeout)
+
+ cache.save(1, 100) # [1]
+ cache.save(2, 200) # [1, 2]
+ cache.save(3, 300) # [1, 2, 3]
+ sleep(1.1) # wait to expire
+ cache.save(4, 400) # [1, 2, 3, 4]
+ cache.save(1, 101) # [2, 3, 4, 1]
+
+ expect(cache.lookup(1)).to eq 101 # [4, 1]
+ expect(cache.lookup(2)).to be_nil
+ expect(cache.lookup(3)).to be_nil
+ expect(cache.lookup(4)).to eq 400
+ end
+
+ it 'should make element stale after timeout even with lookup' do
+ max_timeout = 1
+
+ cache = Optimizely::LRUCache.new(1000, max_timeout)
+
+ cache.save(1, 100)
+ sleep(0.5)
+ cache.lookup(1)
+ sleep(0.5)
+ expect(cache.lookup(1)).to be_nil
+ end
+
+ it 'should not make elements stale when timeout is zero' do
+ max_timeout = 0
+ cache = Optimizely::LRUCache.new(1000, max_timeout)
+
+ cache.save(1, 100) # [1]
+ cache.save(2, 200) # [1, 2]
+ sleep(1) # wait to expire
+
+ expect(cache.lookup(1)).to eq 100
+ expect(cache.lookup(2)).to eq 200
+ end
+
+ it 'should not expire when timeout is less than zero' do
+ max_timeout = -2
+ cache = Optimizely::LRUCache.new(1000, max_timeout)
+
+ cache.save(1, 100) # [1]
+ cache.save(2, 200) # [1, 2]
+ sleep(1) # wait to expire
+
+ expect(cache.lookup(1)).to eq 100
+ expect(cache.lookup(2)).to eq 200
+ end
+
+ it 'should clear cache when reset is called' do
+ cache = Optimizely::LRUCache.new(1000, 600)
+ cache.save('wow', 'great')
+ cache.save('tow', 'freight')
+
+ expect(cache.lookup('wow')).to eq 'great'
+ expect(cache.instance_variable_get('@map').size).to eq 2
+
+ cache.reset
+
+ expect(cache.lookup('wow')).to be_nil
+ expect(cache.instance_variable_get('@map').size).to eq 0
+
+ cache.save('cow', 'crate')
+ expect(cache.lookup('cow')).to eq 'crate'
+ end
+end
diff --git a/spec/odp/odp_event_api_manager_spec.rb b/spec/odp/odp_event_api_manager_spec.rb
new file mode 100644
index 00000000..0f099f01
--- /dev/null
+++ b/spec/odp/odp_event_api_manager_spec.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+require 'spec_helper'
+require 'optimizely/odp/odp_event_api_manager'
+
+describe Optimizely::OdpEventApiManager do
+ let(:user_key) { 'vuid' }
+ let(:user_value) { 'test-user-value' }
+ let(:api_key) { 'test-api-key' }
+ let(:api_host) { 'https://test-host.com' }
+ let(:spy_logger) { spy('logger') }
+ let(:events) do
+ [
+ {type: 't1', action: 'a1', identifiers: {'id-key-1': 'id-value-1'}, data: {'key-1': 'value1'}},
+ {type: 't2', action: 'a2', identifiers: {'id-key-2': 'id-value-2'}, data: {'key-2': 'value2'}}
+ ]
+ end
+ let(:failure_response_data) do
+ {
+ title: 'Bad Request', status: 400, timestamp: '2022-07-01T20:44:00.945Z',
+ detail: {
+ invalids: [{event: 0, message: "missing 'type' field"}]
+ }
+ }.to_json
+ end
+
+ describe '.fetch_segments' do
+ it 'should send odp events successfully and return false' do
+ stub_request(:post, "#{api_host}/v3/events")
+ .with(
+ headers: {'content-type': 'application/json', 'x-api-key': api_key},
+ body: events.to_json
+ ).to_return(status: 200)
+
+ api_manager = Optimizely::OdpEventApiManager.new
+ expect(spy_logger).not_to receive(:log)
+ should_retry = api_manager.send_odp_events(api_key, api_host, events)
+
+ expect(should_retry).to be false
+ end
+
+ it 'should send timeout with custom timeout' do
+ stub_request(:post, "#{api_host}/v3/events")
+ .with(
+ headers: {'content-type': 'application/json', 'x-api-key': api_key},
+ body: events.to_json
+ ).to_return(status: 200)
+
+ api_manager = Optimizely::OdpEventApiManager.new(timeout: 14)
+ expect(Optimizely::Helpers::HttpUtils).to receive(:make_request).with(
+ "#{api_host}/v3/events",
+ :post,
+ events.to_json,
+ {'Content-Type' => 'application/json', 'x-api-key' => api_key},
+ 14,
+ nil
+ ).and_call_original
+
+ should_retry = api_manager.send_odp_events(api_key, api_host, events)
+
+ expect(should_retry).to be false
+ end
+
+ it 'should return true on network error' do
+ allow(Optimizely::Helpers::HttpUtils).to receive(:make_request).and_raise(SocketError)
+ api_manager = Optimizely::OdpEventApiManager.new(logger: spy_logger)
+ expect(spy_logger).to receive(:log).with(Logger::ERROR, 'ODP event send failed (network error).')
+
+ should_retry = api_manager.send_odp_events(api_key, api_host, events)
+
+ expect(should_retry).to be true
+ end
+
+ it 'should return false with 400 error' do
+ stub_request(:post, "#{api_host}/v3/events")
+ .with(
+ body: events.to_json
+ ).to_return(status: [400, 'Bad Request'], body: failure_response_data)
+
+ api_manager = Optimizely::OdpEventApiManager.new(logger: spy_logger)
+ expect(spy_logger).to receive(:log).with(
+ Logger::ERROR, 'ODP event send failed ({"title":"Bad Request","status":400,' \
+ '"timestamp":"2022-07-01T20:44:00.945Z","detail":{"invalids":' \
+ '[{"event":0,"message":"missing \'type\' field"}]}}).'
+ )
+
+ should_retry = api_manager.send_odp_events(api_key, api_host, events)
+
+ expect(should_retry).to be false
+ end
+
+ it 'should return true with 500 error' do
+ stub_request(:post, "#{api_host}/v3/events")
+ .with(
+ body: events.to_json
+ ).to_return(status: [500, 'Internal Server Error'])
+
+ api_manager = Optimizely::OdpEventApiManager.new(logger: spy_logger)
+ expect(spy_logger).to receive(:log).with(Logger::ERROR, 'ODP event send failed (500: Internal Server Error).')
+
+ should_retry = api_manager.send_odp_events(api_key, api_host, events)
+
+ expect(should_retry).to be true
+ end
+ end
+end
diff --git a/spec/odp/odp_event_manager_spec.rb b/spec/odp/odp_event_manager_spec.rb
new file mode 100644
index 00000000..57402887
--- /dev/null
+++ b/spec/odp/odp_event_manager_spec.rb
@@ -0,0 +1,543 @@
+# frozen_string_literal: true
+
+# Copyright 2022, Optimizely
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http:#www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+require 'spec_helper'
+require 'optimizely/odp/odp_event_manager'
+require 'optimizely/odp/odp_event'
+require 'optimizely/odp/lru_cache'
+require 'optimizely/odp/odp_config'
+require 'optimizely/odp/odp_event_api_manager'
+require 'optimizely/logger'
+require 'optimizely/helpers/validator'
+
+describe Optimizely::OdpEventManager do
+ let(:spy_logger) { spy('logger') }
+ let(:api_host) { 'https://test-host' }
+ let(:user_key) { 'fs_user_id' }
+ let(:user_value) { 'test-user-value' }
+ let(:api_key) { 'test-api-key' }
+ let(:segments_to_check) { %w[a b c] }
+ let(:odp_config) { Optimizely::OdpConfig.new(api_key, api_host) }
+ let(:test_uuid) { SecureRandom.uuid }
+ let(:version) { Optimizely::VERSION }
+ let(:events) do
+ [
+ {type: 't1', action: 'a1', identifiers: {'id-key-1': 'id-value-1'}, data: {'key-1': 'value1', "key-2": 2, "key-3": 3.0, "key-4": nil, 'key-5': true, 'key-6': false}},
+ {type: 't2', action: 'a2', identifiers: {'id-key-2': 'id-value-2'}, data: {'key-2': 'value2'}}
+ ]
+ end
+ let(:processed_events) do
+ [
+ {
+ type: 't1',
+ action: 'a1',
+ identifiers: {'id-key-1': 'id-value-1'},
+ data: {
+ idempotence_id: test_uuid,
+ data_source_type: 'sdk',
+ data_source: 'ruby-sdk',
+ data_source_version: version,
+ 'key-1': 'value1',
+ "key-2": 2,
+ "key-3": 3.0,
+ "key-4": nil,
+ "key-5": true,
+ "key-6": false
+ }
+ },
+ {
+ type: 't2',
+ action: 'a2',
+ identifiers: {'id-key-2': 'id-value-2'},
+ data: {
+ idempotence_id: test_uuid,
+ data_source_type: 'sdk',
+ data_source: 'ruby-sdk',
+ data_source_version: version,
+ 'key-2': 'value2'
+ }
+ }
+ ]
+ end
+ let(:odp_events) do
+ [
+ Optimizely::OdpEvent.new(**events[0]),
+ Optimizely::OdpEvent.new(**events[1])
+ ]
+ end
+
+ describe 'OdpEvent#initialize' do
+ it 'should return proper OdpEvent' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event = events[0]
+ expect(Optimizely::Helpers::Validator.odp_data_types_valid?(event[:data])).to be true
+
+ odp_event = Optimizely::OdpEvent.new(**event)
+ expect(odp_event.to_json).to be == processed_events[0].to_json
+ end
+
+ it 'should fail with invalid event' do
+ event = events[0]
+ event[:data]['invalid-item'] = {}
+ expect(Optimizely::Helpers::Validator.odp_data_types_valid?(event[:data])).to be false
+ end
+
+ it 'should convert invalid event identifier' do
+ event = Optimizely::OdpEvent.new(type: 'type', action: 'action', identifiers: {'fs-user-id' => 'great'}, data: {})
+ expect(event.instance_variable_get('@identifiers')).to eq({'fs_user_id' => 'great'})
+
+ event = Optimizely::OdpEvent.new(type: 'type', action: 'action', identifiers: {'FS-user-ID' => 'great'}, data: {})
+ expect(event.instance_variable_get('@identifiers')).to eq({'fs_user_id' => 'great'})
+
+ event = Optimizely::OdpEvent.new(type: 'type', action: 'action', identifiers: {'FS_USER_ID' => 'great', 'fs.user.id' => 'wow'}, data: {})
+ expect(event.instance_variable_get('@identifiers')).to eq({'fs_user_id' => 'great', 'fs.user.id' => 'wow'})
+
+ event = Optimizely::OdpEvent.new(type: 'type', action: 'action', identifiers: {'fs_user_id' => 'great', 'fsuserid' => 'wow'}, data: {})
+ expect(event.instance_variable_get('@identifiers')).to eq({'fs_user_id' => 'great', 'fsuserid' => 'wow'})
+ end
+ end
+
+ describe '#initialize' do
+ it 'should return OdpEventManager instance' do
+ config = Optimizely::OdpConfig.new
+
+ api_manager = Optimizely::OdpEventApiManager.new
+ event_manager = Optimizely::OdpEventManager.new(api_manager: api_manager, logger: spy_logger)
+ event_manager.start!(config)
+
+ expect(event_manager.odp_config).to be config
+ expect(event_manager.api_manager).to be api_manager
+ expect(event_manager.logger).to be spy_logger
+ event_manager.stop!
+
+ event_manager = Optimizely::OdpEventManager.new
+ expect(event_manager.logger).to be_a Optimizely::NoOpLogger
+ expect(event_manager.api_manager).to be_a Optimizely::OdpEventApiManager
+ end
+ end
+
+ describe '#event processing' do
+ it 'should process events successfully' do
+ stub_request(:post, "#{api_host}/v3/events")
+ .to_return(status: 200)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.stop!
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).with(Logger::DEBUG, 'ODP event queue: flushing batch size 2.')
+ expect(spy_logger).to have_received(:log).with(Logger::DEBUG, 'ODP event queue: received shutdown signal.')
+ expect(event_manager.running?).to be false
+ end
+
+ it 'should flush at batch size' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).and_return(false)
+ event_manager.start!(odp_config)
+
+ event_manager.instance_variable_set('@batch_size', 2)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).with(Logger::DEBUG, 'ODP event queue: flushing on batch size.')
+ event_manager.stop!
+ end
+
+ it 'should flush multiple batches' do
+ batch_count = 4
+
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).exactly(batch_count).times.and_return(false)
+ event_manager.start!(odp_config)
+
+ event_manager.instance_variable_set('@batch_size', 2)
+
+ batch_count.times do
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ end
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).exactly(batch_count).times.with(Logger::DEBUG, 'ODP event queue: flushing on batch size.')
+ expect(spy_logger).to have_received(:log).exactly(batch_count).times.with(Logger::DEBUG, 'ODP event queue: flushing batch size 2.')
+
+ event_manager.stop!
+ end
+
+ it 'should process backlog successfully' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ event_manager.odp_config = odp_config
+
+ event_manager.instance_variable_set('@batch_size', 2)
+ batch_count = 4
+ allow(event_manager.api_manager).to receive(:send_odp_events).exactly(batch_count).times.with(api_key, api_host, odp_events).and_return(false)
+
+ # create events before starting processing to simulate backlog
+ allow(event_manager).to receive(:running?).and_return(true)
+ (batch_count - 1).times do
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ end
+ RSpec::Mocks.space.proxy_for(event_manager).remove_stub(:running?)
+ event_manager.start!(odp_config)
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.stop!
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).exactly(batch_count).times.with(Logger::DEBUG, 'ODP event queue: flushing on batch size.')
+ expect(spy_logger).to have_received(:log).exactly(batch_count).times.with(Logger::DEBUG, 'ODP event queue: flushing batch size 2.')
+ end
+
+ it 'should flush with flush signal' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_return(false)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.flush
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: received flush signal.')
+ event_manager.stop!
+ end
+
+ it 'should flush multiple times successfully' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).exactly(4).times.with(api_key, api_host, odp_events).and_return(false)
+ event_manager.start!(odp_config)
+ flush_count = 4
+
+ flush_count.times do
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.flush
+ end
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).to have_received(:log).exactly(flush_count).times.with(Logger::DEBUG, 'ODP event queue: received flush signal.')
+ expect(spy_logger).to have_received(:log).exactly(flush_count).times.with(Logger::DEBUG, 'ODP event queue: flushing batch size 2.')
+
+ event_manager.stop!
+ end
+
+ it 'should log error on retry failure' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ retry_count = event_manager.instance_variable_get('@retry_count')
+ allow(event_manager.api_manager).to receive(:send_odp_events).exactly(retry_count + 1).times.with(api_key, api_host, odp_events).and_return(true)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.flush
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).to have_received(:log).exactly(retry_count).times.with(Logger::DEBUG, 'Error dispatching ODP events, scheduled to retry.')
+ expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "ODP event send failed (Failed after 3 retries: #{processed_events.to_json}).")
+
+ event_manager.stop!
+ end
+
+ it 'should retry on network failure' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_return(true, true, false)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.flush
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).to have_received(:log).twice.with(Logger::DEBUG, 'Error dispatching ODP events, scheduled to retry.')
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(event_manager.running?).to be true
+ event_manager.stop!
+ end
+
+ it 'should log error on send failure' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_raise(StandardError, 'Unexpected error')
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.flush
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "ODP event send failed (Error: Unexpected error #{processed_events.to_json}).")
+ expect(event_manager.running?).to be true
+ event_manager.stop!
+ end
+
+ it 'should log debug when odp disabled' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ odp_config = Optimizely::OdpConfig.new
+ odp_config.update(nil, nil, nil)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).twice.with(Logger::DEBUG, Optimizely::Helpers::Constants::ODP_LOGS[:ODP_NOT_INTEGRATED])
+ expect(event_manager.running?).to be true
+ event_manager.stop!
+ end
+
+ it 'should log error when queue is full' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ stub_const('Optimizely::Helpers::Constants::ODP_EVENT_MANAGER', {DEFAULT_QUEUE_CAPACITY: 1})
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ event_manager.odp_config = odp_config
+ allow(event_manager).to receive(:running?).and_return(true)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.flush
+
+ # warning when adding event to full queue
+ expect(spy_logger).to have_received(:log).once.with(Logger::WARN, 'ODP event send failed (queue full).')
+ # error when trying to flush with full queue
+ expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Error flushing ODP event queue.')
+ end
+
+ it 'should log error on exception within thread' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager).to receive(:add_to_batch).and_raise(StandardError, 'Unexpected error')
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ sleep(0.1)
+ event_manager.send_event(**events[0])
+
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+ expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Uncaught exception processing ODP events. Error: Unexpected error')
+ expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'ODP event send failed (Queue is down).')
+
+ event_manager.stop!
+ end
+
+ it 'should work with overriden event data' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+
+ event = events[0]
+ event[:data][:data_source] = 'my-app'
+ odp_event = Optimizely::OdpEvent.new(**event)
+
+ expect(odp_event.instance_variable_get('@data')[:data_source]).to eq 'my-app'
+
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, [odp_event]).and_return(false)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**event)
+ event_manager.flush
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ event_manager.stop!
+ end
+
+ it 'should flush when flush interval is reached' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_return(false)
+ event_manager.instance_variable_set('@flush_interval', 0.5)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+ sleep(1)
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: flushing on interval.')
+ event_manager.stop!
+ end
+
+ it 'should flush when flush interval is zero' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_return(false)
+ event_manager.instance_variable_set('@flush_interval', 0.0)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: flushing on interval.')
+ event_manager.stop!
+ end
+
+ it 'should discard events received before datafile is ready and process normally' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ odp_config = Optimizely::OdpConfig.new
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_return(false)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+ odp_config.update(api_key, api_host, [])
+ event_manager.update_config
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.flush
+
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).twice.with(Logger::DEBUG, 'ODP event queue: cannot send before the datafile has loaded.')
+ expect(spy_logger).to have_received(:log).twice.with(Logger::DEBUG, 'ODP event queue: adding event.')
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: received flush signal.')
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: received update config signal.')
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: flushing batch size 2.')
+ event_manager.stop!
+ end
+
+ it 'should discard events before and after odp is disabled' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ odp_config = Optimizely::OdpConfig.new
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ expect(event_manager.api_manager).not_to receive(:send_odp_events)
+ event_manager.start!(odp_config)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ odp_config.update(nil, nil, [])
+ event_manager.update_config
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).twice.with(Logger::DEBUG, 'ODP event queue: cannot send before the datafile has loaded.')
+ expect(spy_logger).to have_received(:log).twice.with(Logger::DEBUG, Optimizely::Helpers::Constants::ODP_LOGS[:ODP_NOT_INTEGRATED])
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ event_manager.stop!
+ end
+
+ it 'should begin discarding events if odp is disabled after being enabled' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ odp_config = Optimizely::OdpConfig.new(api_key, api_host)
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_return(false)
+ event_manager.start!(odp_config)
+
+ event_manager.instance_variable_set('@batch_size', 2)
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ odp_config.update(nil, nil, [])
+ event_manager.update_config
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: flushing batch size 2.')
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: received update config signal.')
+ expect(spy_logger).to have_received(:log).twice.with(Logger::DEBUG, Optimizely::Helpers::Constants::ODP_LOGS[:ODP_NOT_INTEGRATED])
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ event_manager.stop!
+ end
+
+ it 'should discard events if odp is disabled after there are events in queue' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ odp_config = Optimizely::OdpConfig.new(api_key, api_host)
+
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ event_manager.odp_config = odp_config
+ event_manager.instance_variable_set('@batch_size', 3)
+
+ allow(event_manager.api_manager).to receive(:send_odp_events).once.with(api_key, api_host, odp_events).and_return(false)
+ allow(event_manager).to receive(:running?).and_return(true)
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+
+ RSpec::Mocks.space.proxy_for(event_manager).remove_stub(:running?)
+
+ event_manager.start!(odp_config)
+ odp_config.update(nil, nil, [])
+ event_manager.update_config
+
+ event_manager.send_event(**events[0])
+ event_manager.send_event(**events[1])
+ event_manager.send_event(**events[0])
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+
+ expect(event_manager.instance_variable_get('@current_batch').length).to eq 0
+ expect(spy_logger).to have_received(:log).exactly(3).times.with(Logger::DEBUG, Optimizely::Helpers::Constants::ODP_LOGS[:ODP_NOT_INTEGRATED])
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ event_manager.stop!
+ end
+
+ it 'should reject events submitted before odp_config is set' do
+ event_manager = Optimizely::OdpEventManager.new(logger: spy_logger)
+ expect(event_manager).not_to receive(:dispatch)
+ event_manager.send_event(**events[0])
+
+ expect(spy_logger).to have_received(:log).once.with(Logger::DEBUG, 'ODP event queue: cannot send before config has been set.')
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+ end
+end
diff --git a/spec/odp/odp_manager_spec.rb b/spec/odp/odp_manager_spec.rb
new file mode 100644
index 00000000..be9ff06f
--- /dev/null
+++ b/spec/odp/odp_manager_spec.rb
@@ -0,0 +1,312 @@
+# frozen_string_literal: true
+
+# Copyright 2022, Optimizely
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http:#www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+require 'spec_helper'
+require 'optimizely/odp/odp_manager'
+require 'optimizely/odp/odp_event_manager'
+require 'optimizely/odp/odp_event'
+require 'optimizely/odp/lru_cache'
+require 'optimizely/odp/odp_config'
+require 'optimizely/odp/odp_event_api_manager'
+require 'optimizely/logger'
+require 'optimizely/helpers/validator'
+require 'optimizely/helpers/constants'
+
+describe Optimizely::OdpManager do
+ let(:spy_logger) { spy('logger') }
+ let(:api_host) { 'https://test-host' }
+ let(:user_key) { 'fs_user_id' }
+ let(:user_value) { 'test-user-value' }
+ let(:api_key) { 'test-api-key' }
+ let(:segments_to_check) { %w[a b c] }
+ let(:test_uuid) { SecureRandom.uuid }
+ let(:event) { {type: 't1', action: 'a1', identifiers: {'id-key-1': 'id-value-1'}, data: {'key-1': 'value1', "key-2": 2, "key-3": 3.0, "key-4": nil, 'key-5': true, 'key-6': false}} }
+ let(:odp_event) { Optimizely::OdpEvent.new(**event) }
+
+ describe '#initialize' do
+ it 'should return default OdpManager instance' do
+ manager = Optimizely::OdpManager.new(disable: false)
+
+ odp_config = manager.instance_variable_get('@odp_config')
+ expect(odp_config).to be_a Optimizely::OdpConfig
+
+ logger = manager.instance_variable_get('@logger')
+ expect(logger).to be_a Optimizely::NoOpLogger
+
+ event_manager = manager.instance_variable_get('@event_manager')
+ expect(event_manager).to be_a Optimizely::OdpEventManager
+ expect(event_manager.logger).to be logger
+ expect(event_manager.running?).to be false
+
+ segment_manager = manager.instance_variable_get('@segment_manager')
+ expect(segment_manager).to be_a Optimizely::OdpSegmentManager
+ expect(segment_manager.odp_config).to be odp_config
+ expect(segment_manager.logger).to be logger
+
+ segments_cache = segment_manager.segments_cache
+ expect(segments_cache).to be_a Optimizely::LRUCache
+ expect(segments_cache.instance_variable_get('@capacity')).to eq 10_000
+ expect(segments_cache.instance_variable_get('@timeout')).to eq 600
+ end
+
+ it 'should allow custom segment_manager' do
+ segments_cache = Optimizely::LRUCache.new(1, 1)
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache)
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ manager = Optimizely::OdpManager.new(disable: false, segments_cache: nil, segment_manager: segment_manager, logger: spy_logger)
+
+ expect(manager.instance_variable_get('@segment_manager')).to be segment_manager
+ expect(manager.instance_variable_get('@segment_manager').instance_variable_get('@segments_cache')).to be segments_cache
+
+ manager.stop!
+ end
+
+ it 'should allow custom segments_cache' do
+ segments_cache = Optimizely::LRUCache.new(1, 1)
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ manager = Optimizely::OdpManager.new(disable: false, segments_cache: segments_cache, logger: spy_logger)
+
+ expect(manager.instance_variable_get('@segment_manager').instance_variable_get('@segments_cache')).to be segments_cache
+
+ manager.stop!
+ end
+
+ it 'should allow custom event_manager' do
+ event_manager = Optimizely::OdpEventManager.new
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ manager = Optimizely::OdpManager.new(disable: false, event_manager: event_manager, logger: spy_logger)
+
+ expect(manager.instance_variable_get('@event_manager')).to be event_manager
+
+ manager.stop!
+ end
+
+ it 'should not instantiate event/segment managers when disabled' do
+ expect(spy_logger).to receive(:log).once.with(Logger::INFO, 'ODP is not enabled.')
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ manager = Optimizely::OdpManager.new(disable: true, logger: spy_logger)
+
+ expect(manager.instance_variable_get('@event_manager')).to be_nil
+ expect(manager.instance_variable_get('@segment_manager')).to be_nil
+ end
+ end
+
+ describe '#fetch_qualified_segments' do
+ it 'should retrieve segments' do
+ segments_cache = Optimizely::LRUCache.new(500, 500)
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache)
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ manager = Optimizely::OdpManager.new(disable: false, segment_manager: segment_manager, logger: spy_logger)
+ manager.update_odp_config(api_key, api_host, segments_to_check)
+
+ cache_key = segment_manager.send(:make_cache_key, user_key, user_value)
+ segments_cache.save(cache_key, [segments_to_check[0]])
+
+ segments = manager.fetch_qualified_segments(user_id: user_value, options: nil)
+
+ expect(segments).to eq [segments_to_check[0]]
+ manager.stop!
+ end
+
+ it 'should log error if disabled' do
+ expect(spy_logger).to receive(:log).once.with(Logger::ERROR, Optimizely::Helpers::Constants::ODP_LOGS[:ODP_NOT_ENABLED])
+ manager = Optimizely::OdpManager.new(disable: true, logger: spy_logger)
+
+ response = manager.fetch_qualified_segments(user_id: 'user1', options: nil)
+ expect(response).to be_nil
+ end
+
+ it 'should log error if datafile not ready' do
+ expect(spy_logger).to receive(:log).with(Logger::ERROR, 'Cannot fetch segments before the datafile has loaded.')
+ manager = Optimizely::OdpManager.new(disable: false, logger: spy_logger)
+
+ response = manager.fetch_qualified_segments(user_id: 'user1', options: nil)
+ expect(response).to be_nil
+ manager.stop!
+ end
+
+ it 'should ignore cache' do
+ segments_cache = Optimizely::LRUCache.new(500, 500)
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+
+ expect(segment_manager.api_manager)
+ .to receive(:fetch_segments)
+ .once
+ .with(api_key, api_host, user_key, user_value, segments_to_check)
+ .and_return([segments_to_check[0]])
+
+ manager = Optimizely::OdpManager.new(disable: false, segment_manager: segment_manager, logger: spy_logger)
+ manager.update_odp_config(api_key, api_host, segments_to_check)
+
+ cache_key = segment_manager.send(:make_cache_key, user_key, user_value)
+ segments_cache.save(cache_key, [segments_to_check[1]])
+
+ segments = manager.fetch_qualified_segments(user_id: user_value, options: [Optimizely::OptimizelySegmentOption::IGNORE_CACHE])
+
+ expect(segments).to eq [segments_to_check[0]]
+ manager.stop!
+ end
+
+ it 'should reset cache' do
+ segments_cache = Optimizely::LRUCache.new(500, 500)
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache)
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+
+ expect(segment_manager.api_manager)
+ .to receive(:fetch_segments)
+ .once
+ .with(api_key, api_host, user_key, user_value, segments_to_check)
+ .and_return([segments_to_check[0]])
+
+ manager = Optimizely::OdpManager.new(disable: false, segment_manager: segment_manager, logger: spy_logger)
+ manager.update_odp_config(api_key, api_host, segments_to_check)
+
+ segments_cache.save('wow', 'great')
+ expect(segments_cache.lookup('wow')).to eq 'great'
+
+ segments = manager.fetch_qualified_segments(user_id: user_value, options: [Optimizely::OptimizelySegmentOption::RESET_CACHE])
+
+ expect(segments).to eq [segments_to_check[0]]
+ expect(segments_cache.lookup('wow')).to be_nil
+ manager.stop!
+ end
+ end
+
+ describe '#send_event' do
+ it 'should send event' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+
+ expect(event_manager.api_manager)
+ .to receive(:send_odp_events)
+ .once
+ .with(api_key, api_host, [odp_event])
+ .and_return(false)
+
+ manager = Optimizely::OdpManager.new(disable: false, event_manager: event_manager, logger: spy_logger)
+ manager.update_odp_config(api_key, api_host, segments_to_check)
+
+ manager.send_event(**event)
+
+ manager.stop!
+ end
+
+ it 'should log error if data is invalid' do
+ expect(spy_logger).to receive(:log).with(Logger::ERROR, 'ODP data is not valid.')
+
+ manager = Optimizely::OdpManager.new(disable: false, logger: spy_logger)
+ manager.update_odp_config(api_key, api_host, segments_to_check)
+ event[:data][:bad_value] = {}
+
+ manager.send_event(**event)
+
+ manager.stop!
+ end
+ end
+
+ describe '#identify_user' do
+ it 'should send event' do
+ allow(SecureRandom).to receive(:uuid).and_return(test_uuid)
+ event_manager = Optimizely::OdpEventManager.new
+ event = Optimizely::OdpEvent.new(type: 'fullstack', action: 'identified', identifiers: {user_key => user_value}, data: {})
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+
+ expect(event_manager.api_manager)
+ .to receive(:send_odp_events)
+ .once
+ .with(api_key, api_host, [event])
+ .and_return(false)
+
+ manager = Optimizely::OdpManager.new(disable: false, event_manager: event_manager, logger: spy_logger)
+ manager.update_odp_config(api_key, api_host, segments_to_check)
+
+ manager.identify_user(user_id: user_value)
+
+ manager.stop!
+ end
+
+ it 'should log debug if disabled' do
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to receive(:log).with(Logger::DEBUG, 'ODP identify event is not dispatched (ODP disabled).')
+
+ manager = Optimizely::OdpManager.new(disable: true, logger: spy_logger)
+ manager.identify_user(user_id: user_value)
+
+ manager.stop!
+ end
+
+ it 'should log debug if not integrated' do
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to receive(:log).with(Logger::DEBUG, 'ODP identify event is not dispatched (ODP not integrated).')
+ manager = Optimizely::OdpManager.new(disable: false, logger: spy_logger)
+ manager.update_odp_config(nil, nil, [])
+ manager.identify_user(user_id: user_value)
+
+ manager.stop!
+ end
+
+ it 'should log debug if datafile not ready' do
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to receive(:log).with(Logger::DEBUG, 'ODP identify event is not dispatched (datafile not ready).')
+
+ manager = Optimizely::OdpManager.new(disable: false, logger: spy_logger)
+ manager.identify_user(user_id: user_value)
+
+ manager.stop!
+ end
+ end
+
+ describe '#update_odp_config' do
+ it 'update config and start event_manager' do
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+ manager = Optimizely::OdpManager.new(disable: false, logger: spy_logger)
+
+ event_manager = manager.instance_variable_get('@event_manager')
+ expect(event_manager.running?).to be false
+
+ segment_manager = manager.instance_variable_get('@segment_manager')
+ segments_cache = segment_manager.instance_variable_get('@segments_cache')
+ segments_cache.save('wow', 'great')
+ expect(segments_cache.lookup('wow')).to eq 'great'
+
+ manager.update_odp_config(api_key, api_host, segments_to_check)
+ expect(event_manager.running?).to be true
+
+ manager_config = manager.instance_variable_get('@odp_config')
+ expect(manager_config.api_host).to eq api_host
+ expect(manager_config.api_key).to eq api_key
+ expect(manager_config.segments_to_check).to eq segments_to_check
+
+ segment_manager_config = segment_manager.odp_config
+ expect(segment_manager_config.api_host).to eq api_host
+ expect(segment_manager_config.api_key).to eq api_key
+ expect(segment_manager_config.segments_to_check).to eq segments_to_check
+ # confirm cache was reset
+ expect(segments_cache.lookup('wow')).to be_nil
+
+ sleep(0.1) until event_manager.instance_variable_get('@event_queue').empty?
+ event_manager_config = event_manager.odp_config
+ expect(event_manager_config.api_host).to eq api_host
+ expect(event_manager_config.api_key).to eq api_key
+ expect(event_manager_config.segments_to_check).to eq segments_to_check
+ # confirm event_manager cached values were updated
+ expect(event_manager.instance_variable_get('@api_host')).to eq api_host
+ expect(event_manager.instance_variable_get('@api_key')).to eq api_key
+
+ manager.stop!
+ end
+ end
+end
diff --git a/spec/odp/odp_segment_api_manager_spec.rb b/spec/odp/odp_segment_api_manager_spec.rb
new file mode 100644
index 00000000..2da77956
--- /dev/null
+++ b/spec/odp/odp_segment_api_manager_spec.rb
@@ -0,0 +1,439 @@
+# frozen_string_literal: true
+
+#
+# Copyright 2022, 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.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+require 'spec_helper'
+require 'optimizely/odp/odp_segment_api_manager'
+
+describe Optimizely::OdpSegmentApiManager do
+ let(:user_key) { 'vuid' }
+ let(:user_value) { 'test-user-value' }
+ let(:api_key) { 'test-api-key' }
+ let(:api_host) { 'https://test-host' }
+ let(:error_handler) { Optimizely::RaiseErrorHandler.new }
+ let(:spy_logger) { spy('logger') }
+ let(:api_manager) { Optimizely::OdpSegmentApiManager.new(logger: spy_logger) }
+ let(:graphql_query) do
+ 'query($userId: String, $audiences: [String]) {' \
+ "customer(#{user_key}: $userId) " \
+ '{audiences(subset: $audiences) {edges {node {name state}}}}}'
+ end
+ let(:good_response_data) do
+ {
+ data: {
+ customer: {
+ audiences: {
+ edges: [
+ {
+ node: {
+ name: 'a',
+ state: 'qualified',
+ description: 'qualifed sample 1'
+ }
+ },
+ {
+ node: {
+ name: 'b',
+ state: 'qualified',
+ description: 'qualifed sample 2'
+ }
+ },
+ {
+ node: {
+ name: 'c',
+ state: 'not_qualified',
+ description: 'not-qualified sample'
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ end
+ let(:good_empty_response_data) do
+ {
+ data: {
+ customer: {
+ audiences: {
+ edges: []
+ }
+ }
+ }
+ }
+ end
+ let(:invalid_identifier_response_data) do
+ {
+ errors: [
+ {
+ message: "Exception while fetching data (/customer) :\
+ java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd",
+ locations: [
+ {
+ line: 2,
+ column: 3
+ }
+ ],
+ path: [
+ 'customer'
+ ],
+ extensions: {
+ classification: 'DataFetchingException',
+ code: 'INVALID_IDENTIFIER_EXCEPTION'
+ }
+ }
+ ],
+ data: {
+ customer: nil
+ }
+ }
+ end
+ let(:node_missing_response_data) do
+ {
+ data: {
+ customer: {
+ audiences: {
+ edges: [
+ {}
+ ]
+ }
+ }
+ }
+ }
+ end
+ let(:mixed_missing_keys_response_data) do
+ {
+ data: {
+ customer: {
+ audiences: {
+ edges: [
+ {
+ node: {
+ state: 'qualified'
+ }
+ },
+ {
+ node: {
+ name: 'a'
+ }
+ },
+ {
+ "other-name": {
+ name: 'a',
+ state: 'qualified'
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ end
+ let(:other_exception_response_data) do
+ {
+ errors: [
+ {
+ message: "Exception while fetching data (/customer) :\
+ java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd",
+ extensions: {
+ classification: 'TestExceptionClass'
+ }
+ }
+ ],
+ data: {
+ customer: nil
+ }
+ }
+ end
+ let(:bad_response_data) { {data: {}} }
+ let(:name_invalid_response_data) do
+ '{
+ "data": {
+ "customer": {
+ "audiences": {
+ "edges": [
+ {
+ "node": {
+ "name": "a":::invalid-part-here:::,
+ "state": "qualified",
+ "description": "qualifed sample 1"
+ }
+ }
+ ]
+ }
+ }
+ }
+ }'
+ end
+ let(:invalid_edges_key_response_data) do
+ {
+ data: {
+ customer: {
+ audiences: {
+ invalid_test_key: [
+ {
+ node: {
+ name: 'a',
+ state: 'qualified',
+ description: 'qualifed sample 1'
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ end
+ let(:invalid_key_for_error_response_data) do
+ {
+ errors: [
+ {
+ message: "Exception while fetching data (/customer) :\
+ java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd",
+ locations: [
+ {
+ line: 2,
+ column: 3
+ }
+ ],
+ path: [
+ 'customer'
+ ],
+ invalid_test_key: {
+ classification: 'InvalidIdentifierException'
+ }
+ }
+ ],
+ data: {
+ customer: nil
+ }
+ }
+ end
+ describe '.fetch_segments' do
+ it 'should get qualified segments when valid segments are given' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .with(
+ headers: {'content-type': 'application/json', 'x-api-key': api_key},
+ body: {query: graphql_query, variables: {userId: user_value, audiences: %w[a b c]}}
+ )
+ .to_return(status: 200, body: good_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b c])
+ expect(segments).to match_array %w[a b]
+ end
+
+ it 'should send timeout for fetch segments with custom timeout' do
+ api_manager_with_timeout = Optimizely::OdpSegmentApiManager.new(logger: spy_logger, timeout: 14)
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .with(
+ headers: {'content-type': 'application/json', 'x-api-key': api_key},
+ body: {query: graphql_query, variables: {userId: user_value, audiences: %w[a b c]}}
+ )
+ .to_return(status: 200, body: good_response_data.to_json)
+ expect(Optimizely::Helpers::HttpUtils).to receive(:make_request).with(anything,
+ anything,
+ anything,
+ anything,
+ 14,
+ nil).and_call_original
+ segments = api_manager_with_timeout.fetch_segments(api_key, api_host, user_key, user_value, %w[a b c])
+ expect(segments).to match_array %w[a b]
+ end
+
+ it 'should get empty array when empty array is given' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: good_empty_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, [])
+ expect(segments).to match_array []
+ end
+
+ it 'should log error and return nil when response is missing node' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: node_missing_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (decode error).'
+ )
+ end
+
+ it 'should log error and return nil when response keys are incorrect' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: mixed_missing_keys_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (decode error).'
+ )
+ end
+
+ it 'should log warning and return nil with invalid identifier exception' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: invalid_identifier_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::WARN,
+ 'Audience segments fetch failed (invalid identifier).'
+ )
+ end
+
+ it 'should log error and return nil with other exception' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: other_exception_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (TestExceptionClass).'
+ )
+ end
+
+ it 'should log error and return nil with bad response data' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: bad_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (decode error).'
+ )
+ end
+
+ it 'should log error and return nil with invalid name' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: name_invalid_response_data)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (JSON decode error).'
+ )
+ end
+
+ it 'should log error and return nil with invalid key' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: invalid_edges_key_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (decode error).'
+ )
+ end
+
+ it 'should log error and return nil with invalid key in error body' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: invalid_key_for_error_response_data.to_json)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (decode error).'
+ )
+ end
+
+ it 'should log error and return nil with network error' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .and_raise(SocketError)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (network error).'
+ )
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::DEBUG,
+ 'GraphQL download failed: Exception from WebMock'
+ )
+ end
+
+ it 'should log error and return nil with http status 400' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 400)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (400).'
+ )
+ end
+
+ it 'should log error and return nil with http status 500' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 500)
+
+ segments = api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b])
+ expect(segments).to be_nil
+
+ expect(spy_logger).to have_received(:log).once.with(
+ Logger::ERROR,
+ 'Audience segments fetch failed (500).'
+ )
+ end
+
+ it 'should create correct subset filter' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .with(body: {query: graphql_query, variables: {userId: user_value, audiences: []}})
+ api_manager.fetch_segments(api_key, api_host, user_key, user_value, nil)
+
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .with(body: {query: graphql_query, variables: {userId: user_value, audiences: []}})
+ api_manager.fetch_segments(api_key, api_host, user_key, user_value, [])
+
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .with(body: {query: graphql_query, variables: {userId: user_value, audiences: %w[a]}})
+ api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a])
+
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .with(body: {query: graphql_query, variables: {userId: user_value, audiences: %w[a b c]}})
+ api_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b c])
+ end
+
+ it 'should pass the proxy config that is passed in' do
+ allow(Optimizely::Helpers::HttpUtils).to receive(:make_request).and_raise(SocketError)
+ stub_request(:post, "#{api_host}/v3/graphql")
+
+ api_manager = Optimizely::OdpSegmentApiManager.new(logger: spy_logger, proxy_config: :proxy_config)
+ api_manager.fetch_segments(api_key, api_host, user_key, user_value, [])
+ expect(Optimizely::Helpers::HttpUtils).to have_received(:make_request).with(anything, anything, anything, anything, anything, :proxy_config)
+ end
+ end
+end
diff --git a/spec/odp/odp_segment_manager_spec.rb b/spec/odp/odp_segment_manager_spec.rb
new file mode 100644
index 00000000..54e299ba
--- /dev/null
+++ b/spec/odp/odp_segment_manager_spec.rb
@@ -0,0 +1,218 @@
+# frozen_string_literal: true
+
+# Copyright 2022, Optimizely
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http:#www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+require 'spec_helper'
+require 'optimizely/odp/odp_segment_manager'
+require 'optimizely/odp/lru_cache'
+require 'optimizely/odp/odp_config'
+require 'optimizely/odp/odp_segment_api_manager'
+require 'optimizely/logger'
+
+describe Optimizely::OdpSegmentManager do
+ let(:spy_logger) { spy('logger') }
+ let(:api_host) { 'https://test-host' }
+ let(:user_key) { 'fs_user_id' }
+ let(:user_value) { 'test-user-value' }
+ let(:api_key) { 'test-api-key' }
+ let(:segments_to_check) { %w[a b c] }
+ let(:segments_cache) { Optimizely::LRUCache.new(1000, 1000) }
+ let(:good_response_data) do
+ {
+ data: {
+ customer: {
+ audiences: {
+ edges: [
+ {
+ node: {
+ name: 'a',
+ state: 'qualified',
+ description: 'qualifed sample 1'
+ }
+ },
+ {
+ node: {
+ name: 'b',
+ state: 'qualified',
+ description: 'qualifed sample 2'
+ }
+ },
+ {
+ node: {
+ name: 'c',
+ state: 'not_qualified',
+ description: 'not-qualified sample'
+ }
+ }
+ ]
+ }
+ }
+ }
+ }.to_json
+ end
+
+ describe '#initialize' do
+ it 'should return OdpSegmentManager instance' do
+ api_manager = Optimizely::OdpSegmentApiManager.new
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, api_manager, spy_logger, nil)
+
+ expect(segment_manager.segments_cache).to be_a Optimizely::LRUCache
+ expect(segment_manager.segments_cache).to be segments_cache
+ expect(segment_manager.odp_config).to be nil
+ expect(segment_manager.api_manager).to be api_manager
+ expect(segment_manager.logger).to be spy_logger
+
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache)
+ expect(segment_manager.logger).to be_a Optimizely::NoOpLogger
+ expect(segment_manager.api_manager).to be_a Optimizely::OdpSegmentApiManager
+ end
+ end
+
+ describe '#fetch_qualified_segments' do
+ it 'should return segments successfully' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .with({headers: {'x-api-key': api_key}, body: {
+ query: 'query($userId: String, $audiences: [String]) {' \
+ "customer(#{user_key}: $userId) " \
+ '{audiences(subset: $audiences) {edges {node {name state}}}}}',
+ variables: {userId: user_value, audiences: %w[a b c]}
+ }})
+ .to_return(status: 200, body: good_response_data)
+
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+ segment_manager.odp_config = Optimizely::OdpConfig.new(api_key, api_host, segments_to_check)
+
+ segments = segment_manager.fetch_qualified_segments(user_key, user_value, [])
+
+ expect(segments).to match_array(%w[a b])
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should return empty array with no segments to check' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: good_response_data)
+
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+ segment_manager.odp_config = Optimizely::OdpConfig.new(api_key, api_host, [])
+
+ segments = segment_manager.fetch_qualified_segments(user_key, user_value, [])
+
+ expect(segments).to match_array([])
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should return success with cache miss' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: good_response_data)
+
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+ segment_manager.odp_config = Optimizely::OdpConfig.new(api_key, api_host, %w[a b c])
+
+ cache_key = segment_manager.send(:make_cache_key, user_key, '123')
+ segment_manager.segments_cache.save(cache_key, %w[d])
+
+ segments = segment_manager.fetch_qualified_segments(user_key, user_value, [])
+
+ expect(segments).to match_array(%w[a b])
+ actual_cache_key = segment_manager.send(:make_cache_key, user_key, user_value)
+ expect(segment_manager.segments_cache.lookup(actual_cache_key)).to match_array(%w[a b])
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should return success with cache hit' do
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+ segment_manager.odp_config = Optimizely::OdpConfig.new(api_key, api_host, %w[a b c])
+
+ cache_key = segment_manager.send(:make_cache_key, user_key, user_value)
+ segment_manager.segments_cache.save(cache_key, %w[c])
+
+ segments = segment_manager.fetch_qualified_segments(user_key, user_value, [])
+
+ expect(segments).to match_array(%w[c])
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should return nil and log error with missing api_host/api_key' do
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+ segment_manager.odp_config = Optimizely::OdpConfig.new
+
+ segments = segment_manager.fetch_qualified_segments(user_key, user_value, [])
+
+ expect(segments).to be_nil
+ expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'Audience segments fetch failed (ODP is not enabled).')
+ end
+
+ it 'should return nil with network error' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 500, body: '{}')
+
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+ segment_manager.odp_config = Optimizely::OdpConfig.new(api_key, api_host, segments_to_check)
+
+ segments = segment_manager.fetch_qualified_segments(user_key, user_value, [])
+
+ expect(segments).to be_nil
+ expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'Audience segments fetch failed (500).')
+ end
+
+ it 'should return non cached value with ignore cache' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: good_response_data)
+
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+ segment_manager.odp_config = Optimizely::OdpConfig.new(api_key, api_host, %w[a b c])
+
+ cache_key = segment_manager.send(:make_cache_key, user_key, user_value)
+ segment_manager.segments_cache.save(cache_key, %w[d])
+
+ segments = segment_manager.fetch_qualified_segments(user_key, user_value, [Optimizely::OptimizelySegmentOption::IGNORE_CACHE])
+
+ expect(segments).to match_array(%w[a b])
+ expect(segment_manager.segments_cache.lookup(cache_key)).to match_array(%w[d])
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should reset cache and return non cached value with reset cache' do
+ stub_request(:post, "#{api_host}/v3/graphql")
+ .to_return(status: 200, body: good_response_data)
+
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+ segment_manager.odp_config = Optimizely::OdpConfig.new(api_key, api_host, %w[a b c])
+
+ cache_key = segment_manager.send(:make_cache_key, user_key, user_value)
+ segment_manager.segments_cache.save(cache_key, %w[d])
+ segment_manager.segments_cache.save('123', %w[c d])
+
+ segments = segment_manager.fetch_qualified_segments(user_key, user_value, [Optimizely::OptimizelySegmentOption::RESET_CACHE])
+
+ expect(segments).to match_array(%w[a b])
+ expect(segment_manager.segments_cache.lookup(cache_key)).to match_array(%w[a b])
+ expect(segment_manager.segments_cache.instance_variable_get('@map').length).to be 1
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should make correct cache key' do
+ segment_manager = Optimizely::OdpSegmentManager.new(nil, nil)
+ cache_key = segment_manager.send(:make_cache_key, user_key, user_value)
+ expect(cache_key).to be == "#{user_key}-$-#{user_value}"
+ end
+
+ it 'should log error if odp_config not set' do
+ segment_manager = Optimizely::OdpSegmentManager.new(segments_cache, nil, spy_logger, nil)
+
+ response = segment_manager.fetch_qualified_segments(user_key, user_value, [])
+ expect(response).to be_nil
+ expect(spy_logger).to have_received(:log).with(Logger::ERROR, 'Audience segments fetch failed (ODP is not enabled).')
+ end
+ end
+end
diff --git a/spec/optimizely_config_spec.rb b/spec/optimizely_config_spec.rb
index f95e8f10..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,17 +27,23 @@
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
+ project_instance_sim_keys.close
+ project_instance_typed_audiences.close
+ project_instance_similar_rule_keys.close
+ end
it 'should return all experiments' do
experiments_map = optimizely_config['experimentsMap']
@@ -762,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)
@@ -790,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_factory_spec.rb b/spec/optimizely_factory_spec.rb
index c293f5da..65c8d4d5 100644
--- a/spec/optimizely_factory_spec.rb
+++ b/spec/optimizely_factory_spec.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.
@@ -29,6 +29,8 @@
let(:user_profile_service) { spy('user_profile_service') }
let(:event_dispatcher) { Optimizely::EventDispatcher.new }
let(:notification_center) { Optimizely::NotificationCenter.new(spy_logger, error_handler) }
+ let(:config_body_integrations) { OptimizelySpec::CONFIG_DICT_WITH_INTEGRATIONS }
+ let(:config_body_integrations_JSON) { OptimizelySpec::CONFIG_DICT_WITH_INTEGRATIONS_JSON }
before(:example) do
WebMock.allow_net_connect!
@@ -68,7 +70,7 @@
describe '.default_instance_with_manager' do
it 'should take provided custom config manager' do
class CustomConfigManager # rubocop:disable Lint/ConstantDefinitionInBlock
- attr_reader :config
+ attr_reader :config, :sdk_key
end
custom_config_manager = CustomConfigManager.new
@@ -131,6 +133,21 @@ class CustomConfigManager # rubocop:disable Lint/ConstantDefinitionInBlock
expect(logger).to be(optimizely_instance.logger)
expect(notification_center).to be(optimizely_instance.notification_center)
end
+
+ it 'should update odp_config correctly' do
+ stub_request(:get, 'https://cdn.optimizely.com/datafiles/instance-test.json')
+ .to_return(status: 200, body: config_body_integrations_JSON)
+ project = Optimizely::OptimizelyFactory.custom_instance('instance-test')
+
+ # wait for config to be ready
+ project.config_manager.config
+
+ odp_config = project.instance_variable_get('@odp_manager').instance_variable_get('@odp_config')
+ expect(odp_config.api_key).to eq config_body_integrations['integrations'][0]['publicKey']
+ expect(odp_config.api_host).to eq config_body_integrations['integrations'][0]['host']
+
+ project.close
+ end
end
describe '.max_event_batch_size' do
diff --git a/spec/optimizely_user_context_spec.rb b/spec/optimizely_user_context_spec.rb
index 3c3be2e4..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,10 +27,81 @@
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) }
- 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
+ {
+ data: {
+ customer: {
+ audiences: {
+ edges: [
+ {
+ node: {
+ name: 'a',
+ state: 'qualified',
+ description: 'qualifed sample 1'
+ }
+ },
+ {
+ node: {
+ name: 'b',
+ state: 'qualified',
+ description: 'qualifed sample 2'
+ }
+ },
+ {
+ node: {
+ name: 'c',
+ state: 'not_qualified',
+ description: 'not-qualified sample'
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ end
+ let(:integrated_response_data) do
+ {
+ data: {
+ customer: {
+ audiences: {
+ edges: [
+ {
+ node: {
+ name: 'odp-segment-1',
+ state: 'qualified',
+ description: 'qualifed sample 1'
+ }
+ },
+ {
+ node: {
+ name: 'odp-segment-none',
+ state: 'qualified',
+ description: 'qualifed sample 2'
+ }
+ },
+ {
+ node: {
+ name: 'odp-segment-2',
+ state: 'not_qualified',
+ description: 'not-qualified sample'
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ end
+ after(:example) do
+ project_instance.close
+ forced_decision_project_instance.close
+ integration_project_instance.close
+ end
describe '#initialize' do
it 'should set passed value as expected' do
@@ -47,6 +118,11 @@
user_context_obj = Optimizely::OptimizelyUserContext.new(project_instance, 'test_user', nil)
expect(user_context_obj.instance_variable_get(:@user_attributes)).to eq({})
end
+
+ it 'should not fail with a nil client' do
+ user_context_obj = Optimizely::OptimizelyUserContext.new(nil, 'test-user', nil)
+ expect(user_context_obj).to be_a Optimizely::OptimizelyUserContext
+ end
end
describe '#set_attribute' do
@@ -175,13 +251,19 @@
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)
forced_decision = Optimizely::OptimizelyUserContext::OptimizelyForcedDecision.new('3324490562')
user_context_obj.set_forced_decision(context, forced_decision)
decision = user_context_obj.decide(feature_key)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until forced_decision_project_instance.event_processor.event_queue.empty?
+
expect(forced_decision_project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, expected_params, post_headers))
expect(decision.variation_key).to eq('3324490562')
expect(decision.rule_key).to be_nil
@@ -267,13 +349,19 @@
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')
forced_decision = Optimizely::OptimizelyUserContext::OptimizelyForcedDecision.new('b')
user_context_obj.set_forced_decision(context, forced_decision)
decision = user_context_obj.decide(feature_key, [Optimizely::Decide::OptimizelyDecideOption::INCLUDE_REASONS])
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until forced_decision_project_instance.event_processor.event_queue.empty?
+
expect(forced_decision_project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, expected_params, post_headers))
expect(decision.variation_key).to eq('b')
expect(decision.rule_key).to eq('exp_with_audience')
@@ -306,7 +394,7 @@
expect(decision.user_context.forced_decisions).to eq(context => forced_decision)
expect(decision.reasons).to eq(['Variation (3324490633) is mapped to flag (feature_1), rule (exp_with_audience) and user (tester) in the forced decision map.'])
end
- expected.to raise_error
+ expected.to raise_error Optimizely::InvalidVariationError
end
it 'should return correct variation if rule in forced decision is deleted' do
@@ -380,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)
@@ -395,6 +485,10 @@
user_context_obj.remove_forced_decision(context_with_rule)
# decision should be based on flag forced decision
decision = user_context_obj.decide(feature_key)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until forced_decision_project_instance.event_processor.event_queue.empty?
+
expect(forced_decision_project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, expected_params, post_headers))
expect(decision.variation_key).to eq('3324490562')
expect(decision.rule_key).to be_nil
@@ -725,6 +819,7 @@
end
end
it 'should clone qualified segments in user context' do
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
qualified_segments = %w[seg1 seg2]
user_context_obj.qualified_segments = qualified_segments
@@ -734,64 +829,261 @@
expect(user_clone_1.qualified_segments).to eq qualified_segments
expect(user_clone_1.qualified_segments).not_to be user_context_obj.qualified_segments
expect(user_clone_1.qualified_segments).not_to be qualified_segments
+ integration_project_instance.close
end
it 'should hit segment in ab test' do
stub_request(:post, impression_log_url)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
user_context_obj.qualified_segments = %w[odp-segment-1 odp-segment-none]
decision = user_context_obj.decide('flag-segment')
expect(decision.variation_key).to eq 'variation-a'
+ integration_project_instance.close
end
it 'should hit other audience with segments in ab test' do
stub_request(:post, impression_log_url)
- user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', 'age' => 30)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {'age' => 30})
user_context_obj.qualified_segments = %w[odp-segment-none]
decision = user_context_obj.decide('flag-segment', [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE])
expect(decision.variation_key).to eq 'variation-a'
+ integration_project_instance.close
end
it 'should hit segment in rollout' do
stub_request(:post, impression_log_url)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
user_context_obj.qualified_segments = %w[odp-segment-2]
decision = user_context_obj.decide('flag-segment', [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE])
expect(decision.variation_key).to eq 'rollout-variation-on'
+ integration_project_instance.close
end
it 'should miss segment in rollout' do
stub_request(:post, impression_log_url)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
user_context_obj.qualified_segments = %w[odp-segment-none]
decision = user_context_obj.decide('flag-segment', [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE])
expect(decision.variation_key).to eq 'rollout-variation-off'
+ integration_project_instance.close
end
it 'should miss segment with empty segments' do
stub_request(:post, impression_log_url)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
user_context_obj.qualified_segments = []
decision = user_context_obj.decide('flag-segment', [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE])
expect(decision.variation_key).to eq 'rollout-variation-off'
+ integration_project_instance.close
end
it 'should not fail without any segments' do
stub_request(:post, impression_log_url)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
decision = user_context_obj.decide('flag-segment', [Optimizely::Decide::OptimizelyDecideOption::IGNORE_USER_PROFILE_SERVICE])
expect(decision.variation_key).to eq 'rollout-variation-off'
+ integration_project_instance.close
+ end
+
+ it 'should send identify event when user context created' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: good_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ expect(integration_project_instance.odp_manager).to receive(:identify_user).with({user_id: 'tester'})
+ Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+
+ integration_project_instance.close
+ end
+
+ it 'should skip identify with decisions' do
+ stub_request(:post, impression_log_url)
+ expect(integration_project_instance.odp_manager).to receive(:identify_user).with({user_id: 'tester'})
+ expect(spy_logger).not_to receive(:log).with(Logger::ERROR, anything)
+
+ user_context = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+
+ expect(integration_project_instance.odp_manager).not_to receive(:identify_user)
+
+ user_context.decide('flag-segment')
+ user_context.decide_all
+ user_context.decide_for_keys(['flag-segment'])
+
+ integration_project_instance.close
+ end
+
+ describe '#fetch_qualified_segments' do
+ it 'should fetch segments' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: good_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+
+ success = user_context_obj.fetch_qualified_segments
+
+ expect(user_context_obj.qualified_segments).to eq %w[a b]
+ expect(success).to be true
+ integration_project_instance.close
+ end
+
+ it 'should save empty array when not qualified for any segments' do
+ good_response_data[:data][:customer][:audiences][:edges].map { |e| e[:node][:state] = 'unqualified' }
+
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: good_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+
+ success = user_context_obj.fetch_qualified_segments
+
+ expect(user_context_obj.qualified_segments).to eq []
+ expect(success).to be true
+ integration_project_instance.close
+ end
+
+ it 'should fetch segments and reset cache' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: good_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ segments_cache = integration_project_instance.odp_manager.instance_variable_get('@segment_manager').instance_variable_get('@segments_cache')
+ segments_cache.save('wow', 'great')
+ expect(segments_cache.lookup('wow')).to eq 'great'
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+
+ success = user_context_obj.fetch_qualified_segments(options: [:RESET_CACHE])
+
+ expect(segments_cache.lookup('wow')).to be_nil
+ expect(user_context_obj.qualified_segments).to eq %w[a b]
+ expect(success).to be true
+ integration_project_instance.close
+ end
+
+ it 'should fetch segments from cache' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: good_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+
+ segment_manager = integration_project_instance.odp_manager.instance_variable_get('@segment_manager')
+ cache_key = segment_manager.send(:make_cache_key, Optimizely::Helpers::Constants::ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID], 'tester')
+
+ segments_cache = segment_manager.instance_variable_get('@segments_cache')
+ segments_cache.save(cache_key, %w[great])
+ expect(segments_cache.lookup(cache_key)).to eq %w[great]
+
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+ success = user_context_obj.fetch_qualified_segments
+
+ expect(user_context_obj.qualified_segments).to eq %w[great]
+ expect(success).to be true
+ integration_project_instance.close
+ end
+
+ it 'should fetch segments and ignore cache' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: good_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+
+ segment_manager = integration_project_instance.odp_manager.instance_variable_get('@segment_manager')
+ cache_key = segment_manager.send(:make_cache_key, Optimizely::Helpers::Constants::ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID], 'tester')
+
+ segments_cache = segment_manager.instance_variable_get('@segments_cache')
+ segments_cache.save(cache_key, %w[great])
+ expect(segments_cache.lookup(cache_key)).to eq %w[great]
+
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+ success = user_context_obj.fetch_qualified_segments(options: [:IGNORE_CACHE])
+
+ expect(user_context_obj.qualified_segments).to eq %w[a b]
+ expect(success).to be true
+ expect(segments_cache.lookup(cache_key)).to eq %w[great]
+ integration_project_instance.close
+ end
+
+ it 'should return false on error' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 500)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+
+ success = user_context_obj.fetch_qualified_segments
+
+ expect(user_context_obj.qualified_segments).to be_nil
+ expect(success).to be false
+ integration_project_instance.close
+ end
+
+ it 'should not raise error with a nil client' do
+ user_context_obj = Optimizely::OptimizelyUserContext.new(nil, 'tester', {})
+ user_context_obj.fetch_qualified_segments
+ end
+
+ it 'should fetch segments when non-blocking' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: good_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+
+ user_context_obj.fetch_qualified_segments do |success|
+ expect(success).to be true
+ expect(user_context_obj.qualified_segments).to eq %w[a b]
+ integration_project_instance.close
+ end
+ end
+
+ it 'should pass false to callback when failed and non-blocking' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 500)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+
+ thread = user_context_obj.fetch_qualified_segments do |success|
+ expect(success).to be false
+ expect(user_context_obj.qualified_segments).to be_nil
+ end
+ thread.join
+ integration_project_instance.close
+ end
+
+ it 'should fetch segments from cache with non-blocking' do
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: good_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+
+ segment_manager = integration_project_instance.odp_manager.instance_variable_get('@segment_manager')
+ cache_key = segment_manager.send(:make_cache_key, Optimizely::Helpers::Constants::ODP_MANAGER_CONFIG[:KEY_FOR_USER_ID], 'tester')
+
+ segments_cache = segment_manager.instance_variable_get('@segments_cache')
+ segments_cache.save(cache_key, %w[great])
+ expect(segments_cache.lookup(cache_key)).to eq %w[great]
+
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+ thread = user_context_obj.fetch_qualified_segments do |success|
+ expect(success).to be true
+ expect(user_context_obj.qualified_segments).to eq %w[great]
+ end
+ thread.join
+ integration_project_instance.close
+ end
+
+ it 'should decide correctly with non-blocking' do
+ stub_request(:post, impression_log_url)
+ stub_request(:post, 'https://api.zaius.com/v3/graphql').to_return(status: 200, body: integrated_response_data.to_json)
+ stub_request(:post, 'https://api.zaius.com/v3/events').to_return(status: 200)
+ user_context_obj = Optimizely::OptimizelyUserContext.new(integration_project_instance, 'tester', {})
+ thread = user_context_obj.fetch_qualified_segments do |success|
+ expect(success).to be true
+ decision = user_context_obj.decide('flag-segment')
+ expect(decision.variation_key).to eq 'variation-a'
+ end
+ thread.join
+ integration_project_instance.close
+ end
end
end
diff --git a/spec/project_spec.rb b/spec/project_spec.rb
index c9232099..f857a5ce 100644
--- a/spec/project_spec.rb
+++ b/spec/project_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
#
-# Copyright 2016-2020, 2022, Optimizely and contributors
+# Copyright 2016-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.
@@ -23,23 +23,36 @@
require 'optimizely/event/batch_event_processor'
require 'optimizely/exceptions'
require 'optimizely/helpers/validator'
+require 'optimizely/helpers/sdk_settings'
require 'optimizely/optimizely_user_context'
require 'optimizely/version'
describe 'Optimizely' do
- let(:config_body) { OptimizelySpec::VALID_CONFIG_BODY }
- let(:config_body_JSON) { OptimizelySpec::VALID_CONFIG_BODY_JSON }
+ # need different sdk_key for every instance, otherwise notification center callbacks get called for the wrong tests
+ let!(:sdk_key) { SecureRandom.uuid }
+ let(:config_body) do
+ datafile = OptimizelySpec::VALID_CONFIG_BODY.dup
+ datafile['sdkKey'] = sdk_key
+ datafile
+ end
+ let(:config_body_JSON) { JSON.dump(config_body) }
let(:config_body_invalid_JSON) { OptimizelySpec::INVALID_CONFIG_BODY_JSON }
- let(:config_body_integrations) { OptimizelySpec::CONFIG_DICT_WITH_INTEGRATIONS }
+ let(:config_body_integrations) do
+ datafile = OptimizelySpec::CONFIG_DICT_WITH_INTEGRATIONS.dup
+ datafile['sdkKey'] = sdk_key
+ datafile
+ end
+ let(:config_body_integrations_JSON) { JSON.dump(config_body_integrations) }
let(:error_handler) { Optimizely::RaiseErrorHandler.new }
let(:spy_logger) { spy('logger') }
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) }
+ 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'} }
+ after(:example) { project_instance.close }
it 'has a version number' do
expect(Optimizely::VERSION).not_to be_nil
@@ -52,14 +65,15 @@
describe '.initialize' do
it 'should take in a custom logger when instantiating Project class' do
class CustomLogger # rubocop:disable Lint/ConstantDefinitionInBlock
- def log(log_message)
+ def log(_level, log_message)
log_message
end
end
logger = CustomLogger.new
- instance_with_logger = Optimizely::Project.new(config_body_JSON, nil, logger)
- expect(instance_with_logger.logger.log('test_message')).to eq('test_message')
+ 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
it 'should take in a custom error handler when instantiating Project class' do
@@ -70,99 +84,115 @@ 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_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.')
- Optimizely::Project.new(nil)
+ expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.')
+ Optimizely::Project.new(logger: spy_logger).close
end
it 'should log an error when datafile is empty' do
- expect_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.')
- Optimizely::Project.new('')
+ expect(spy_logger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.')
+ 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
- expect_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.')
- Optimizely::Project.new('{"foo": "bar"}')
+ allow(spy_logger).to receive(:log).with(Logger::INFO, anything)
+ 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(datafile: '{"foo": "bar"}', logger: spy_logger).close
end
it 'should log an error when given an invalid logger' do
- expect_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided logger is in an invalid format.')
+ allow(Optimizely::SimpleLogger).to receive(:new).and_return(spy_logger)
+ allow(spy_logger).to receive(:log).with(Logger::DEBUG, anything)
+ allow(spy_logger).to receive(:log).with(Logger::INFO, anything)
+ 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)
+ Optimizely::Project.new(datafile: config_body_JSON, logger: InvalidLogger.new).close
end
it 'should log an error when given an invalid event_dispatcher' do
+ allow_any_instance_of(Optimizely::SimpleLogger).to receive(:log).with(Logger::INFO, anything)
+ allow_any_instance_of(Optimizely::SimpleLogger).to receive(:log).with(Logger::DEBUG, anything)
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)
+ 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
+ allow_any_instance_of(Optimizely::SimpleLogger).to receive(:log).with(Logger::INFO, anything)
+ allow_any_instance_of(Optimizely::SimpleLogger).to receive(:log).with(Logger::DEBUG, anything)
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)
+ 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)
+ Optimizely::Project.new(datafile: config_body_JSON, skip_json_validation: true).close
end
it 'should be invalid when datafile contains integrations missing key' do
- expect_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.')
- config = config_body_integrations.dup
+ # allow(Optimizely::SimpleLogger).to receive(:new).and_return(spy_logger)
+ allow(spy_logger).to receive(:log).with(Logger::INFO, anything)
+ 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.')
+ config = OptimizelySpec.deep_clone(config_body_integrations)
config['integrations'][0].delete('key')
integrations_json = JSON.dump(config)
- Optimizely::Project.new(integrations_json)
+ Optimizely::Project.new(datafile: integrations_json, logger: spy_logger)
end
it 'should be valid when datafile contains integrations with only key' do
- config = config_body_integrations.dup
+ config = OptimizelySpec.deep_clone(config_body_integrations)
config['integrations'].clear
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
it 'should be valid when datafile contains integrations with arbitrary fields' do
- config = config_body_integrations.dup
+ config = OptimizelySpec.deep_clone(config_body_integrations)
config['integrations'].clear
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
it 'should log and raise an error when provided a datafile that is not JSON and skip_json_validation is true' do
- expect_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.')
+ 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, nil, 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_any_instance_of(Optimizely::SimpleLogger).to receive(:log).once.with(Logger::ERROR, 'Provided datafile is in an invalid format.')
+ 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, nil, 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_any_instance_of(Optimizely::SimpleLogger).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(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, nil, 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
@@ -193,6 +223,14 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
'browser' => 'chrome'
)).to be_instance_of(Optimizely::OptimizelyUserContext)
end
+
+ it 'should send identify event when called with odp enabled' do
+ 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')
+
+ project.close
+ end
end
describe '#activate' do
@@ -253,6 +291,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
stub_request(:post, impression_log_url).with(query: params)
expect(project_instance.activate('test_experiment', 'test_user')).to eq('control')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
expect(project_instance.decision_service.bucketer).to have_received(:bucket).once
end
@@ -271,6 +313,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
stub_request(:post, impression_log_url).with(query: params)
expect(project_instance.activate('test_experiment', 'test_user')).to eq('control')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
end
@@ -303,13 +349,17 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
expect(project_instance.activate('test_experiment_with_audience', 'test_user', 'browser_type' => 'firefox'))
.to eq('control_with_audience')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
expect(project_instance.decision_service.bucketer).to have_received(:bucket).once
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, event_processor_options: {batch_size: 1})
@project_config = @project_typed_audience_instance.config_manager.config
@expected_activate_params = {
account_id: '4879520872',
@@ -347,6 +397,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
}
end
+ after(:example) do
+ @project_typed_audience_instance.close
+ end
+
it 'should properly activate a user, (with attributes provided) when there is a typed audience with exact match type string' do
params = @expected_activate_params
@@ -379,6 +433,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
# Should be included via exact match string audience with id '3468206642'
expect(@project_typed_audience_instance.activate('typed_audience_experiment', 'test_user', 'house' => 'Gryffindor'))
.to eq('A')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until @project_typed_audience_instance.event_processor.event_queue.empty?
+
expect(@project_typed_audience_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
expect(@project_typed_audience_instance.decision_service.bucketer).to have_received(:bucket).once
end
@@ -415,6 +473,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
# Should be included via exact match number audience with id '3468206646'
expect(@project_typed_audience_instance.activate('typed_audience_experiment', 'test_user', 'lasers' => 45.5))
.to eq('A')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until @project_typed_audience_instance.event_processor.event_queue.empty?
+
expect(@project_typed_audience_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
expect(@project_typed_audience_instance.decision_service.bucketer).to have_received(:bucket).once
end
@@ -472,6 +534,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
expect(@project_typed_audience_instance.activate('audience_combinations_experiment', 'test_user', user_attributes))
.to eq('A')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until @project_typed_audience_instance.event_processor.event_queue.empty?
+
expect(@project_typed_audience_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
expect(@project_typed_audience_instance.decision_service.bucketer).to have_received(:bucket).once
end
@@ -484,6 +550,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
expect(@project_typed_audience_instance.activate('audience_combinations_experiment', 'test_user', user_attributes))
.to eq(nil)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(@project_typed_audience_instance.event_dispatcher).not_to have_received(:dispatch_event)
expect(@project_typed_audience_instance.decision_service.bucketer).not_to have_received(:bucket)
end
@@ -541,6 +611,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
expect(project_instance.activate('test_experiment_with_audience', 'test_user', attributes))
.to eq('control_with_audience')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
expect(project_instance.decision_service.bucketer).to have_received(:bucket).once
end
@@ -587,6 +661,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
expect(project_instance.activate('test_experiment_with_audience', 'test_user', attributes))
.to eq('control_with_audience')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
expect(project_instance.decision_service.bucketer).to have_received(:bucket).once
end
@@ -622,6 +700,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
expect(project_instance.activate('test_experiment_with_audience', 'test_user', 'browser_type' => 'firefox'))
.to eq('variation_with_audience')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
end
@@ -710,6 +792,10 @@ class InvalidErrorHandler; end # rubocop:disable Lint/ConstantDefinitionInBlock
allow(project_instance.decision_service.bucketer).to receive(:bucket).and_return(nil)
expect(project_instance.activate('test_experiment', 'test_user')).to eq(nil)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Not activating user 'test_user'.")
expect(project_instance.event_dispatcher).to_not have_received(:dispatch_event)
end
@@ -743,10 +829,13 @@ def callback(_args); end
Optimizely::NotificationCenter::NOTIFICATION_TYPES[:ACTIVATE],
experiment, 'test_user', nil, variation_to_return,
instance_of(Optimizely::Event)
- ).ordered
+ )
project_instance.activate('test_experiment', 'test_user')
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Activating user 'test_user' in experiment 'test_experiment'.")
end
@@ -760,12 +849,19 @@ def callback(_args); end
allow(project_instance.decision_service.bucketer).to receive(:bucket).and_return(variation_to_return)
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(any_args).and_raise(RuntimeError)
project_instance.activate('test_experiment', 'test_user')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "Error dispatching event: #{log_event} RuntimeError.")
end
it 'should raise an exception when called with invalid attributes' do
expect { project_instance.activate('test_experiment', 'test_user', 'invalid') }
.to raise_error(Optimizely::InvalidAttributeFormatError)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
end
it 'should override the audience check if the user is whitelisted to a specific variation' do
@@ -795,18 +891,20 @@ def callback(_args); end
expect(project_instance.activate('test_experiment_with_audience', 'forced_audience_user', 'browser_type' => 'wrong_browser'))
.to eq('variation_with_audience')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, impression_log_url, params, post_headers)).once
expect(Optimizely::Audience).to_not have_received(:user_in_experiment?)
end
it 'should log an error when called with an invalid Project object' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, 'Provided datafile is in an invalid format.')
expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Optimizely instance is not valid. Failing 'activate'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -852,7 +950,7 @@ def callback(_args); end
describe '.Optimizely with config manager' do
before(:example) do
stub_request(:post, impression_log_url)
- stub_request(:get, 'https://cdn.optimizely.com/datafiles/valid_sdk_key.json')
+ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json")
.with(
headers: {
'Content-Type' => 'application/json'
@@ -872,19 +970,21 @@ def callback(_args); end
expect(notification_center).to receive(:send_notifications).ordered
http_project_config_manager = Optimizely::HTTPProjectConfigManager.new(
- url: 'https://cdn.optimizely.com/datafiles/valid_sdk_key.json',
+ sdk_key: sdk_key,
+ url: "https://cdn.optimizely.com/datafiles/#{sdk_key}.json",
notification_center: notification_center
)
- project_instance = Optimizely::Project.new(
- nil, nil, spy_logger, error_handler,
- false, nil, nil, http_project_config_manager, notification_center
+ custom_project_instance = Optimizely::Project.new(
+ 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?
expect(http_project_config_manager.config).not_to eq(nil)
- expect(project_instance.activate('test_experiment', 'test_user')).not_to eq(nil)
+ expect(custom_project_instance.activate('test_experiment', 'test_user')).not_to eq(nil)
+ custom_project_instance.close
end
it 'should update config, send update notification when sdk key is provided' do
@@ -898,26 +998,27 @@ def callback(_args); end
expect(notification_center).to receive(:send_notifications).ordered
http_project_config_manager = Optimizely::HTTPProjectConfigManager.new(
- sdk_key: 'valid_sdk_key',
+ sdk_key: sdk_key,
notification_center: notification_center
)
- project_instance = Optimizely::Project.new(
- nil, nil, spy_logger, error_handler,
- false, nil, nil, http_project_config_manager, notification_center
+ custom_project_instance = Optimizely::Project.new(
+ 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?
expect(http_project_config_manager.config).not_to eq(nil)
- expect(project_instance.activate('test_experiment', 'test_user')).not_to eq(nil)
+ expect(custom_project_instance.activate('test_experiment', 'test_user')).not_to eq(nil)
+ custom_project_instance.close
end
end
describe '.Optimizely with sdk key' do
before(:example) do
stub_request(:post, impression_log_url)
- stub_request(:get, 'https://cdn.optimizely.com/datafiles/valid_sdk_key.json')
+ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json")
.with(
headers: {
'Content-Type' => 'application/json'
@@ -935,15 +1036,16 @@ def callback(_args); end
expect(notification_center).to receive(:send_notifications).ordered
expect(notification_center).to receive(:send_notifications).ordered
- project_instance = Optimizely::Project.new(
- nil, nil, spy_logger, error_handler,
- false, nil, 'valid_sdk_key', nil, notification_center
+ custom_project_instance = Optimizely::Project.new(
+ logger: spy_logger, error_handler: error_handler,
+ sdk_key: sdk_key, notification_center: notification_center
)
- sleep 0.1 until project_instance.config_manager.ready?
+ sleep 0.1 until custom_project_instance.config_manager.ready?
- expect(project_instance.is_valid).to be true
- expect(project_instance.activate('test_experiment', 'test_user')).not_to eq(nil)
+ expect(custom_project_instance.is_valid).to be true
+ expect(custom_project_instance.activate('test_experiment', 'test_user')).not_to eq(nil)
+ custom_project_instance.close
end
end
end
@@ -1011,6 +1113,10 @@ def callback(_args); end
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
project_instance.track('test_event', 'test_user')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
end
@@ -1018,19 +1124,28 @@ def callback(_args); end
project_instance.decision_service.set_forced_variation(project_config, 'test_experiment', 'test_user', 'variation')
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
project_instance.track('test_event', 'test_user')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, @expected_track_event_params, post_headers)).once
end
it 'should properly track an event with tags even when the project does not have a custom logger' do
- project_instance = Optimizely::Project.new(config_body_JSON)
+ 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}
- project_instance.decision_service.set_forced_variation(project_config, 'test_experiment', 'test_user', 'variation')
- allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
- project_instance.track('test_event', 'test_user', nil, revenue: 42)
- expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
+ custom_project_instance.decision_service.set_forced_variation(project_config, 'test_experiment', 'test_user', 'variation')
+ allow(custom_project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
+ custom_project_instance.track('test_event', 'test_user', nil, revenue: 42)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until custom_project_instance.event_processor.event_queue.empty?
+
+ expect(custom_project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
+ custom_project_instance.close
end
it 'should log a message if an exception has occurred during dispatching of the event' do
@@ -1041,6 +1156,10 @@ def callback(_args); end
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(any_args).and_raise(RuntimeError)
project_instance.track('test_event', 'test_user')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, "Error dispatching event: #{log_event} RuntimeError.")
end
@@ -1066,9 +1185,13 @@ def callback(_args); end
.with(
Optimizely::NotificationCenter::NOTIFICATION_TYPES[:TRACK],
'test_event', 'test_user', nil, {'revenue' => 42}, conversion_event
- ).ordered
+ )
project_instance.track('test_event', 'test_user', nil, 'revenue' => 42)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
end
@@ -1085,12 +1208,16 @@ def callback(_args); end
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
project_instance.track('test_event_with_audience', 'test_user', 'browser_type' => 'firefox')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
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, event_processor_options: {batch_size: 1})
@expected_event_params = {
account_id: '4879520872',
project_id: '11624721371',
@@ -1127,11 +1254,18 @@ def callback(_args); end
client_version: Optimizely::VERSION
}
end
+ after(:example) do
+ @project_typed_audience_instance.close
+ end
it 'should call dispatch_event with right params when attributes are provided' do
# Should be included via substring match string audience with id '3988293898'
allow(@project_typed_audience_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
@project_typed_audience_instance.track('item_bought', 'test_user', 'house' => 'Welcome to Slytherin!')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until @project_typed_audience_instance.event_processor.event_queue.empty?
+
expect(@project_typed_audience_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, @expected_event_params, post_headers)).once
end
@@ -1140,6 +1274,10 @@ def callback(_args); end
params[:visitors][0][:attributes][0][:value] = 'Welcome to Hufflepuff!'
allow(@project_typed_audience_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
@project_typed_audience_instance.track('item_bought', 'test_user', 'house' => 'Welcome to Hufflepuff!')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until @project_typed_audience_instance.event_processor.event_queue.empty?
+
expect(@project_typed_audience_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
end
@@ -1170,6 +1308,10 @@ def callback(_args); end
params[:visitors][0][:snapshots][0][:events][0][:key] = 'user_signed_up'
allow(@project_typed_audience_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
@project_typed_audience_instance.track('user_signed_up', 'test_user', user_attributes)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until @project_typed_audience_instance.event_processor.event_queue.empty?
+
expect(@project_typed_audience_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
end
end
@@ -1187,6 +1329,10 @@ def callback(_args); end
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
project_instance.track('test_event_with_audience', 'test_user', 'browser_type' => 'cyberdog')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
end
@@ -1196,6 +1342,10 @@ def callback(_args); end
params[:visitors][0][:snapshots][0][:events][0][:key] = 'test_event_not_running'
allow(project_instance.event_dispatcher).to receive(:dispatch_event).with(instance_of(Optimizely::Event))
project_instance.track('test_event_not_running', 'test_user')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
end
@@ -1265,18 +1415,20 @@ def callback(_args); end
allow(Optimizely::Audience).to receive(:user_in_experiment?)
project_instance.track('test_event_with_audience', 'forced_audience_user', 'browser_type' => 'wrong_browser')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(Optimizely::Audience).to_not have_received(:user_in_experiment?)
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(Optimizely::Event.new(:post, conversion_log_url, params, post_headers)).once
end
it 'should log an error when called with an invalid Project object' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, 'Provided datafile is in an invalid format.')
expect(spy_logger).to have_received(:log).with(Logger::ERROR, "Optimizely instance is not valid. Failing 'track'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -1380,13 +1532,11 @@ def callback(_args); end
end
it 'should log an error when called with an invalid Project object' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -1470,13 +1620,11 @@ def callback(_args); end
end
it 'should return false when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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'.")
+ invalid_project.close
end
it 'should return false when the feature flag key is nil' do
@@ -1536,6 +1684,10 @@ def callback(_args); end
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil)
expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be(false)
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'multi_variate_feature' is not enabled for user 'test_user'.")
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(instance_of(Optimizely::Event)).once
end
@@ -1554,6 +1706,10 @@ def callback(_args); end
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
expect(project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user')).to be true
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'boolean_single_variable_feature' is enabled for user 'test_user'.")
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(instance_of(Optimizely::Event)).once
end
@@ -1571,6 +1727,10 @@ def callback(_args); end
expect(variation_to_return['featureEnabled']).to be false
expect(project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user')).to be false
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'boolean_single_variable_feature' is not enabled for user 'test_user'.")
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(instance_of(Optimizely::Event)).once
end
@@ -1588,15 +1748,22 @@ def callback(_args); end
expect(variation_to_return['featureEnabled']).to be true
expect(project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user')).to be true
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'boolean_single_variable_feature' is enabled for user 'test_user'.")
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(instance_of(Optimizely::Event)).once
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
+ @project_typed_audience_instance.close
+ end
it 'should return true for feature rollout when typed audience matched' do
# Should be included via exists match audience with id '3988293899'
@@ -1660,7 +1827,7 @@ def callback(_args); end
expect(project_instance.notification_center).to receive(:send_notifications)
.with(
Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args
- ).ordered
+ )
expect(project_instance.notification_center).to receive(:send_notifications)
.with(
@@ -1677,6 +1844,10 @@ def callback(_args); end
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be true
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Activating user 'test_user' in experiment 'test_experiment_multivariate'.")
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'multi_variate_feature' is enabled for user 'test_user'.")
end
@@ -1694,6 +1865,10 @@ def callback(_args); end
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
expect(project_instance.is_feature_enabled('multi_variate_feature', 'test_user')).to be false
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(project_instance.event_dispatcher).to have_received(:dispatch_event).with(instance_of(Optimizely::Event)).once
expect(spy_logger).to have_received(:log).once.with(Logger::INFO, "Feature 'multi_variate_feature' is not enabled for user 'test_user'.")
end
@@ -1716,7 +1891,9 @@ def callback(_args); end
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
# Activate listener
- expect(project_instance.notification_center).to receive(:send_notifications).ordered
+ expect(project_instance.notification_center).to receive(:send_notifications).once.with(
+ Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args
+ )
# Decision listener called when the user is in experiment with variation feature on.
expect(variation_to_return['featureEnabled']).to be true
@@ -1733,6 +1910,9 @@ def callback(_args); end
).ordered
project_instance.is_feature_enabled('multi_variate_feature', 'test_user')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
end
it 'should call decision listener when user is bucketed into a feature experiment with featureEnabled property is false' do
@@ -1747,7 +1927,9 @@ def callback(_args); end
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
- expect(project_instance.notification_center).to receive(:send_notifications).ordered
+ expect(project_instance.notification_center).to receive(:send_notifications).once.with(
+ Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args
+ ).ordered
# DECISION listener called when the user is in experiment with variation feature off.
expect(variation_to_return['featureEnabled']).to be false
@@ -1764,6 +1946,9 @@ def callback(_args); end
)
project_instance.is_feature_enabled('multi_variate_feature', 'test_user', 'browser_type' => 'chrome')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
end
it 'should call decision listener when user is bucketed into rollout with featureEnabled property is true' do
@@ -1779,7 +1964,9 @@ def callback(_args); end
# DECISION listener called when the user is in rollout with variation feature true.
expect(variation_to_return['featureEnabled']).to be true
- expect(project_instance.notification_center).to receive(:send_notifications).ordered
+ expect(project_instance.notification_center).to receive(:send_notifications).once.with(
+ Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args
+ ).ordered
expect(project_instance.notification_center).to receive(:send_notifications).once.with(
Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION],
'feature', 'test_user', {'browser_type' => 'firefox'},
@@ -1790,6 +1977,9 @@ def callback(_args); end
)
project_instance.is_feature_enabled('boolean_single_variable_feature', 'test_user', 'browser_type' => 'firefox')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
end
it 'should call decision listener when user is bucketed into rollout with featureEnabled property is false' do
@@ -1810,7 +2000,9 @@ def callback(_args); end
it 'call decision listener when the user is not bucketed into any experiment or rollout' do
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(nil)
- expect(project_instance.notification_center).to receive(:send_notifications).ordered
+ expect(project_instance.notification_center).to receive(:send_notifications).once.with(
+ Optimizely::NotificationCenter::NOTIFICATION_TYPES[:LOG_EVENT], any_args
+ ).ordered
expect(project_instance.notification_center).to receive(:send_notifications).with(
Optimizely::NotificationCenter::NOTIFICATION_TYPES[:DECISION],
@@ -1822,19 +2014,20 @@ def callback(_args); end
)
project_instance.is_feature_enabled('multi_variate_feature', 'test_user', 'browser_type' => 'firefox')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
end
end
end
describe '#get_enabled_features' do
it 'should return empty when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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'.")
+ invalid_project.close
end
it 'should call inputs_valid? with the proper arguments in get_enabled_features' do
@@ -1886,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
@@ -2056,14 +2249,12 @@ def callback(_args); end
user_attributes = {}
it 'should return nil when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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_feature_variable_string'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -2148,7 +2339,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable_string('string_single_variable_feature', 'string_variable', user_id, user_attributes))
.to eq('cta_1')
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2164,7 +2355,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable_string('string_single_variable_feature', 'string_variable', user_id, user_attributes))
.to eq('wingardium leviosa')
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2177,7 +2368,6 @@ def callback(_args); end
it 'should log an error message and return nil' do
expect(project_instance.get_feature_variable_string('totally_invalid_feature_key', 'string_variable', user_id, user_attributes))
.to eq(nil)
- expect(spy_logger).to have_received(:log).exactly(2).times
expect(spy_logger).to have_received(:log).once
.with(
Logger::ERROR,
@@ -2195,7 +2385,6 @@ def callback(_args); end
it 'should log an error message and return nil' do
expect(project_instance.get_feature_variable_string('string_single_variable_feature', 'invalid_string_variable', user_id, user_attributes))
.to eq(nil)
- expect(spy_logger).to have_received(:log).once
expect(spy_logger).to have_received(:log).once
.with(
Logger::ERROR,
@@ -2210,14 +2399,12 @@ def callback(_args); end
user_attributes = {}
it 'should return nil when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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_feature_variable_json'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -2326,7 +2513,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable_json('json_single_variable_feature', 'json_variable', user_id, user_attributes))
.to eq('value' => 'cta_1')
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2354,7 +2541,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable_json('json_single_variable_feature', 'json_variable', user_id, user_attributes))
.to eq('val' => 'wingardium leviosa')
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2367,17 +2554,13 @@ def callback(_args); end
it 'should log an error message and return nil' do
expect(project_instance.get_feature_variable_json('totally_invalid_feature_key', 'json_variable', user_id, user_attributes))
.to eq(nil)
- expect(spy_logger).to have_received(:log).twice
expect(spy_logger).to have_received(:log).once
.with(
Logger::ERROR,
"Feature flag key 'totally_invalid_feature_key' is not in datafile."
)
- expect(spy_logger).to have_received(:log).once
- .with(
- Logger::INFO,
- "No feature flag was found for key 'totally_invalid_feature_key'."
- )
+ expect(spy_logger).to have_received(:log)
+ .with(Logger::INFO, "No feature flag was found for key 'totally_invalid_feature_key'.")
end
end
@@ -2385,7 +2568,6 @@ def callback(_args); end
it 'should log an error message and return nil' do
expect(project_instance.get_feature_variable_json('json_single_variable_feature', 'invalid_json_variable', user_id, user_attributes))
.to eq(nil)
- expect(spy_logger).to have_received(:log).once
expect(spy_logger).to have_received(:log).once
.with(
Logger::ERROR,
@@ -2400,14 +2582,12 @@ def callback(_args); end
user_attributes = {}
it 'should return nil when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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_feature_variable_boolean'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -2432,8 +2612,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable_boolean('boolean_single_variable_feature', 'boolean_variable', user_id, user_attributes))
.to eq(true)
-
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2447,14 +2626,12 @@ def callback(_args); end
user_attributes = {}
it 'should return nil when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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_feature_variable_double'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -2481,7 +2658,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable_double('double_single_variable_feature', 'double_variable', user_id, user_attributes))
.to eq(42.42)
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2495,14 +2672,12 @@ def callback(_args); end
user_attributes = {}
it 'should return nil when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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_feature_variable_integer'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -2529,7 +2704,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable_integer('integer_single_variable_feature', 'integer_variable', user_id, user_attributes))
.to eq(42)
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2543,14 +2718,12 @@ def callback(_args); end
user_attributes = {}
it 'should return nil when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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_all_feature_variables'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -2759,7 +2932,6 @@ def callback(_args); end
it 'should log an error message and return nil' do
expect(project_instance.get_all_feature_variables('totally_invalid_feature_key', user_id, user_attributes))
.to eq(nil)
- expect(spy_logger).to have_received(:log).twice
expect(spy_logger).to have_received(:log).once
.with(
Logger::ERROR,
@@ -2779,14 +2951,12 @@ def callback(_args); end
user_attributes = {}
it 'should return nil when called with invalid project config' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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_feature_variable'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -2831,7 +3001,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable('string_single_variable_feature', 'string_variable', user_id, user_attributes))
.to eq('cta_1')
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2852,7 +3022,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable('boolean_single_variable_feature', 'boolean_variable', user_id, user_attributes))
.to eq(true)
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2874,7 +3044,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable('double_single_variable_feature', 'double_variable', user_id, user_attributes))
.to eq(42.42)
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2896,7 +3066,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable('integer_single_variable_feature', 'integer_variable', user_id, user_attributes))
.to eq(42)
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2912,7 +3082,7 @@ def callback(_args); end
expect(project_instance.get_feature_variable('string_single_variable_feature', 'string_variable', user_id, user_attributes))
.to eq('wingardium leviosa')
- expect(spy_logger).to have_received(:log).once
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once
.with(
Logger::INFO,
@@ -2925,7 +3095,6 @@ def callback(_args); end
it 'should log an error message and return nil' do
expect(project_instance.get_feature_variable('totally_invalid_feature_key', 'string_variable', user_id, user_attributes))
.to eq(nil)
- expect(spy_logger).to have_received(:log).twice
expect(spy_logger).to have_received(:log).once
.with(
Logger::ERROR,
@@ -2943,7 +3112,6 @@ def callback(_args); end
it 'should log an error message and return nil' do
expect(project_instance.get_feature_variable('string_single_variable_feature', 'invalid_string_variable', user_id, user_attributes))
.to eq(nil)
- expect(spy_logger).to have_received(:log).once
expect(spy_logger).to have_received(:log).once
.with(
Logger::ERROR,
@@ -2993,7 +3161,10 @@ 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
end
it 'should return variable value when typed audience match' do
@@ -3295,13 +3466,11 @@ def callback(_args); end
valid_variation = {id: '111128', key: 'control'}
it 'should log an error when called with an invalid Project object' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -3354,13 +3523,11 @@ def callback(_args); end
valid_experiment = {id: '111127', key: 'test_experiment'}
it 'should log an error when called with an invalid Project object' do
- logger = double('logger')
- allow(logger).to receive(:log)
- allow(Optimizely::SimpleLogger).to receive(:new) { logger }
- 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(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, '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'.")
+ invalid_project.close
end
it 'should return nil and log an error when Config Manager returns nil config' do
@@ -3402,15 +3569,16 @@ 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
end
describe '.close' do
before(:example) do
stub_request(:post, impression_log_url)
- stub_request(:get, 'https://cdn.optimizely.com/datafiles/valid_sdk_key.json')
+ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json")
.with(
headers: {
'Content-Type' => 'application/json'
@@ -3421,15 +3589,15 @@ def callback(_args); end
it 'should stop config manager and event processor when optimizely close is called' do
config_manager = Optimizely::HTTPProjectConfigManager.new(
- sdk_key: 'valid_sdk_key',
+ sdk_key: sdk_key,
start_by_default: true
)
event_processor = Optimizely::BatchEventProcessor.new(event_dispatcher: Optimizely::EventDispatcher.new)
- Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler)
+ 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
@@ -3445,12 +3613,12 @@ def callback(_args); end
it 'should stop invalid object' do
http_project_config_manager = Optimizely::HTTPProjectConfigManager.new(
- sdk_key: 'valid_sdk_key'
+ sdk_key: sdk_key
)
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
@@ -3459,12 +3627,12 @@ def callback(_args); end
it 'shoud return optimizely as invalid for an API when close is called' do
http_project_config_manager = Optimizely::HTTPProjectConfigManager.new(
- sdk_key: 'valid_sdk_key'
+ sdk_key: sdk_key
)
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?
@@ -3484,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
@@ -3498,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
@@ -3528,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(
@@ -3540,6 +3708,7 @@ def callback(_args); end
variables: {},
variation_key: nil
)
+ invalid_project.close
end
it 'when flag key is invalid' do
@@ -3589,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(
@@ -3597,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(
@@ -3630,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(
@@ -3638,9 +3813,15 @@ 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')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(decision.as_json).to include(
flag_key: 'multi_variate_feature',
enabled: true,
@@ -3706,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))
@@ -3715,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(
@@ -3743,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))
@@ -3780,13 +3966,19 @@ 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
allow(project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
user_context = project_instance.create_user_context('user1')
decision = project_instance.decide(user_context, 'multi_variate_feature')
+
+ # wait for batch processing thread to send event
+ sleep 0.1 until project_instance.event_processor.event_queue.empty?
+
expect(decision.as_json).to include(
flag_key: 'multi_variate_feature',
enabled: false,
@@ -3878,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(
@@ -3901,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(
@@ -3919,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],
@@ -3940,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])
@@ -3978,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')
@@ -4003,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
@@ -4039,10 +4237,11 @@ 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({})
+ invalid_project.close
end
it 'should get all the decisions' do
@@ -4105,10 +4304,11 @@ 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({})
+ invalid_project.close
end
it 'should get all the decisions for keys' do
@@ -4175,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
@@ -4206,13 +4406,14 @@ def callback(_args); end
variables: {'integer_variable' => 42},
variation_key: 'control'
)
+ custom_project_instance.close
end
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(
@@ -4220,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(
@@ -4233,12 +4435,13 @@ def callback(_args); end
variables: {'first_letter' => 'F', 'rest_of_name' => 'red'},
variation_key: 'Fred'
)
+ custom_project_instance.close
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]
@@ -4247,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(
@@ -4260,14 +4464,15 @@ def callback(_args); end
variables: {},
variation_key: 'Fred'
)
+ custom_project_instance.close
end
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)
@@ -4290,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')
@@ -4311,10 +4518,11 @@ def callback(_args); end
variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'},
variation_key: nil
)
+ custom_project_instance.close
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)
@@ -4329,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')
@@ -4343,12 +4553,13 @@ def callback(_args); end
variables: {'first_letter' => 'H', 'rest_of_name' => 'arry'},
variation_key: nil
)
+ custom_project_instance.close
end
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))
@@ -4360,12 +4571,13 @@ def callback(_args); end
allow(custom_project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
user_context = custom_project_instance.create_user_context('user1')
custom_project_instance.decide(user_context, 'multi_variate_feature')
+ custom_project_instance.close
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]
@@ -4378,7 +4590,354 @@ def callback(_args); end
allow(custom_project_instance.decision_service).to receive(:get_variation_for_feature).and_return(decision_to_return)
user_context = custom_project_instance.create_user_context('user1')
custom_project_instance.decide(user_context, 'multi_variate_feature')
+ custom_project_instance.close
+ end
+ end
+ end
+
+ describe 'sdk_settings' do
+ it 'should log info when disabled' do
+ project_instance.close
+ 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(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
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).once.with(Logger::INFO, 'ODP is not enabled.')
+ end
+
+ it 'should accept zero for flush interval' do
+ 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(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
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should use default for flush interval when nil' do
+ 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(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
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should accept cache_size' do
+ 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: 5)
+ 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
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should accept cache_timeout' do
+ 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(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
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should accept cache_size and cache_timeout' do
+ 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(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
+ expect(segments_cache.timeout).to eq 5
+ project.close
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should use default cache_size and cache_timeout when not provided' do
+ 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(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
+ expect(segments_cache.timeout).to eq 600
+ project.close
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should accept zero cache_size and cache_timeout' do
+ 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(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
+ expect(segments_cache.timeout).to eq 0
+ project.close
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should accept valid custom cache' do
+ class CustomCache # rubocop:disable Lint/ConstantDefinitionInBlock
+ def reset; end
+ def lookup(key); end
+ def save(key, value); end
+ 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(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
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should revert to default cache when custom cache is invalid' do
+ 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(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
+ project.close
+
+ expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Invalid ODP segments cache, reverting to default.')
+ end
+
+ it 'should accept valid custom segment manager' do
+ class CustomSegmentManager # rubocop:disable Lint/ConstantDefinitionInBlock
+ attr_accessor :odp_config
+
+ def initialize
+ @odp_config = nil
+ end
+
+ def reset; end
+ def fetch_qualified_segments(user_key, user_value, options); end
+ 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(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')
+ project.close
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).to have_received(:log).once.with(Logger::INFO, 'Stopping ODP event queue.')
+ end
+
+ it 'should revert to default segment manager when custom manager is invalid' do
+ class InvalidSegmentManager; 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_segment_manager: InvalidSegmentManager.new)
+ 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
+ project.close
+
+ expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Invalid ODP segment manager, reverting to default.')
+ end
+
+ it 'should accept valid custom event manager' do
+ class CustomEventManager # rubocop:disable Lint/ConstantDefinitionInBlock
+ attr_accessor :odp_event_timeout
+
+ def send_event(extra_param = nil, action:, type:, identifiers:, data:, other_extra_param: 'great'); end
+ def start!(odp_config); end
+ def update_config; end
+ def stop!; end
+ def running?; end
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(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'})
+ project.close
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ end
+
+ it 'should revert to default event manager when custom manager is invalid' do
+ 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(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
+ project.close
+
+ expect(spy_logger).to have_received(:log).once.with(Logger::ERROR, 'Invalid ODP event manager, reverting to default.')
+ end
+ end
+
+ describe '#send_odp_event' do
+ it 'should send event with StaticProjectConfigManager' do
+ 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(datafile: config_body_integrations_JSON, logger: spy_logger)
+ project.send_odp_event(type: 'wow', action: 'great', identifiers: {amazing: 'fantastic'}, data: {})
+ project.close
+ end
+
+ it 'should send event with HTTPProjectConfigManager' do
+ datafile = OptimizelySpec.deep_clone(config_body_integrations)
+ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json")
+ .to_return(status: 200, body: JSON.dump(datafile))
+ 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(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?
+
+ project.send_odp_event(type: 'wow', action: 'great', identifiers: {amazing: 'fantastic'}, data: {})
+ project.close
+ end
+
+ 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(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
+
+ it 'should log error if datafile is invalid' do
+ 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(logger: spy_logger, sdk_key: sdk_key)
+ project.send_odp_event(type: 'wow', action: 'great', identifiers: {amazing: 'fantastic'}, data: {})
+ project.close
+ end
+
+ it 'should log error if odp not enabled with HTTPProjectConfigManager' do
+ stub_request(:get, "https://cdn.optimizely.com/datafiles/#{sdk_key}.json")
+ .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(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(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(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(datafile: config_body_integrations_JSON, logger: spy_logger)
+ project.send_odp_event(type: 'wow', action: 'great', identifiers: nil, data: {'wow': {}})
+ project.close
+ end
+
+ it 'should not send odp events with legacy apis' do
+ experiment_key = 'experiment-segment'
+ feature_key = 'flag-segment'
+ user_id = 'test_user'
+
+ 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)
+
+ project.activate(experiment_key, user_id)
+ project.track('event1', user_id)
+ project.get_variation(experiment_key, user_id)
+ project.get_all_feature_variables(feature_key, user_id)
+ project.is_feature_enabled(feature_key, user_id)
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+
+ project.close
+ end
+
+ 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(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(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(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: {})
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+
+ project.close
+ end
+
+ it 'should use default with empty string type' do
+ 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: {})
+
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+
+ project.close
end
end
end
diff --git a/spec/spec_params.rb b/spec/spec_params.rb
index 62c585a9..e43ce3cc 100644
--- a/spec/spec_params.rb
+++ b/spec/spec_params.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
#
-# Copyright 2016-2021, Optimizely and contributors
+# Copyright 2016-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.
@@ -1240,7 +1240,7 @@ module OptimizelySpec
'integrations' => [
{
'key' => 'odp',
- 'host' => 'https =>//api.zaius.com',
+ 'host' => 'https://api.zaius.com',
'publicKey' => 'W4WzcEs-ABgXorzY7h1LCQ'
}
],
@@ -1321,8 +1321,15 @@ module OptimizelySpec
}
],
'accountId' => '10367498574',
- 'events' => [],
- 'revision' => '101'
+ 'events' => [
+ {
+ 'experimentIds' => ['10420810910'],
+ 'id' => '10404198134',
+ 'key' => 'event1'
+ }
+ ],
+ 'revision' => '101',
+ 'sdkKey' => 'INTEGRATIONS'
}.freeze
SIMILAR_EXP_KEYS = {
@@ -1930,4 +1937,21 @@ module OptimizelySpec
# SEND_FLAG_DECISIONS_DISABLED_CONFIG['sendFlagDecisions'] = false
CONFIG_DICT_WITH_INTEGRATIONS_JSON = JSON.dump(CONFIG_DICT_WITH_INTEGRATIONS)
+
+ def self.deep_clone(obj)
+ obj.dup.tap do |new_obj|
+ case new_obj
+ when Hash
+ new_obj.each do |key, val|
+ new_obj[key] = deep_clone(val)
+ end
+ when Array
+ new_obj.map! do |val|
+ deep_clone(val)
+ end
+ else
+ new_obj
+ end
+ end
+ end
end
diff --git a/spec/user_condition_evaluator_spec.rb b/spec/user_condition_evaluator_spec.rb
index d928cce3..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.
@@ -18,6 +18,8 @@
require 'json'
require 'spec_helper'
require 'optimizely/helpers/validator'
+require 'optimizely/event/forwarding_event_processor'
+require 'optimizely/event_dispatcher'
require 'optimizely/logger'
describe Optimizely::UserConditionEvaluator do
@@ -25,8 +27,10 @@
let(:config_body_JSON) { OptimizelySpec::VALID_CONFIG_BODY_JSON }
let(:error_handler) { Optimizely::NoOpErrorHandler.new }
let(:spy_logger) { spy('logger') }
- let(:project_instance) { Optimizely::Project.new(config_body_JSON, nil, spy_logger, error_handler) }
+ let(:event_processor) { Optimizely::ForwardingEventProcessor.new(Optimizely::EventDispatcher.new) }
+ 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 }
it 'should return true when the attributes pass the audience conditions and no match type is provided' do
user_context.instance_variable_set(:@user_attributes, 'browser_type' => 'safari')
@@ -61,7 +65,7 @@
user_context.instance_variable_set(:@user_attributes, 'weird_condition' => 'bye')
condition_evaluator = Optimizely::UserConditionEvaluator.new(user_context, spy_logger)
expect(condition_evaluator.evaluate(condition)).to eq(nil)
- expect(spy_logger).to have_received(:log).exactly(1).times
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once.with(
Logger::WARN,
"Audience condition #{condition} uses an unknown condition type. You may need to upgrade to a newer release of " \
@@ -74,7 +78,7 @@
user_context.instance_variable_set(:@user_attributes, 'weird_condition' => 'bye')
condition_evaluator = Optimizely::UserConditionEvaluator.new(user_context, spy_logger)
expect(condition_evaluator.evaluate(condition)).to eq(nil)
- expect(spy_logger).to have_received(:log).exactly(1).times
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
expect(spy_logger).to have_received(:log).once.with(
Logger::WARN,
"Audience condition #{condition} uses an unknown condition type. You may need to upgrade to a newer release of " \
@@ -102,7 +106,8 @@
it 'should return false if there is no user-provided value' do
condition_evaluator = Optimizely::UserConditionEvaluator.new(user_context, spy_logger)
expect(condition_evaluator.evaluate(@exists_conditions)).to be false
- expect(spy_logger).not_to have_received(:log)
+ expect(spy_logger).not_to have_received(:log).with(Logger::ERROR, anything)
+ expect(spy_logger).not_to have_received(:log).with(Logger::WARN, anything)
end
it 'should return false if the user-provided value is nil' do
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