├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── actions │ ├── build-docs │ │ └── action.yml │ ├── check │ │ └── action.yml │ ├── publish-docs │ │ └── action.yml │ ├── publish │ │ └── action.yml │ └── setup │ │ └── action.yml ├── pull_request_template.md └── workflows │ ├── build-gem.yml │ ├── ci.yml │ ├── lint-pr-title.yml │ ├── manual-publish-docs.yml │ ├── manual-publish.yml │ ├── release-please.yml │ └── stale.yml ├── .gitignore ├── .release-please-manifest.json ├── .rspec ├── .rubocop.yml ├── .sdk_metadata.json ├── .simplecov ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── Makefile ├── PROVENANCE.md ├── README.md ├── SECURITY.md ├── contract-tests ├── Gemfile ├── README.md ├── big_segment_store_fixture.rb ├── client_entity.rb ├── hook.rb └── service.rb ├── docs ├── Makefile └── index.md ├── launchdarkly-server-sdk.gemspec ├── lib ├── launchdarkly-server-sdk.rb ├── ldclient-rb.rb └── ldclient-rb │ ├── cache_store.rb │ ├── config.rb │ ├── context.rb │ ├── evaluation_detail.rb │ ├── events.rb │ ├── expiring_cache.rb │ ├── flags_state.rb │ ├── impl.rb │ ├── impl │ ├── big_segments.rb │ ├── broadcaster.rb │ ├── context.rb │ ├── context_filter.rb │ ├── data_source.rb │ ├── data_store.rb │ ├── dependency_tracker.rb │ ├── diagnostic_events.rb │ ├── evaluation_with_hook_result.rb │ ├── evaluator.rb │ ├── evaluator_bucketing.rb │ ├── evaluator_helpers.rb │ ├── evaluator_operators.rb │ ├── event_sender.rb │ ├── event_summarizer.rb │ ├── event_types.rb │ ├── flag_tracker.rb │ ├── integrations │ │ ├── consul_impl.rb │ │ ├── dynamodb_impl.rb │ │ ├── file_data_source.rb │ │ ├── redis_impl.rb │ │ └── test_data │ │ │ └── test_data_source.rb │ ├── migrations │ │ ├── migrator.rb │ │ └── tracker.rb │ ├── model │ │ ├── clause.rb │ │ ├── feature_flag.rb │ │ ├── preprocessed_data.rb │ │ ├── segment.rb │ │ └── serialization.rb │ ├── repeating_task.rb │ ├── sampler.rb │ ├── store_client_wrapper.rb │ ├── store_data_set_sorter.rb │ ├── unbounded_pool.rb │ └── util.rb │ ├── in_memory_store.rb │ ├── integrations.rb │ ├── integrations │ ├── consul.rb │ ├── dynamodb.rb │ ├── file_data.rb │ ├── redis.rb │ ├── test_data.rb │ ├── test_data │ │ └── flag_builder.rb │ └── util │ │ └── store_wrapper.rb │ ├── interfaces.rb │ ├── ldclient.rb │ ├── memoized_value.rb │ ├── migrations.rb │ ├── non_blocking_thread_pool.rb │ ├── polling.rb │ ├── reference.rb │ ├── requestor.rb │ ├── simple_lru_cache.rb │ ├── stream.rb │ ├── util.rb │ └── version.rb ├── release-please-config.json └── spec ├── big_segment_store_spec_base.rb ├── capturing_logger.rb ├── config_spec.rb ├── context_spec.rb ├── diagnostic_events_spec.rb ├── evaluation_detail_spec.rb ├── events_spec.rb ├── events_test_util.rb ├── expiring_cache_spec.rb ├── feature_store_spec_base.rb ├── fixtures ├── feature.json ├── feature1.json └── user.json ├── flags_state_spec.rb ├── http_util.rb ├── impl ├── big_segments_spec.rb ├── context_spec.rb ├── data_source_spec.rb ├── data_store_spec.rb ├── evaluator_big_segments_spec.rb ├── evaluator_bucketing_spec.rb ├── evaluator_clause_spec.rb ├── evaluator_operators_spec.rb ├── evaluator_prereq_spec.rb ├── evaluator_rule_spec.rb ├── evaluator_segment_spec.rb ├── evaluator_spec.rb ├── evaluator_spec_base.rb ├── event_sender_spec.rb ├── event_summarizer_spec.rb ├── flag_tracker_spec.rb ├── migrations │ ├── migrator_spec.rb │ └── tracker_spec.rb ├── model │ ├── model_validation_spec.rb │ ├── preprocessed_data_spec.rb │ └── serialization_spec.rb ├── repeating_task_spec.rb ├── sampler_spec.rb ├── store_client_wrapper_spec.rb └── util_spec.rb ├── in_memory_feature_store_spec.rb ├── integrations ├── consul_feature_store_spec.rb ├── dynamodb_stores_spec.rb ├── file_data_source_spec.rb ├── redis_stores_spec.rb ├── test_data_spec.rb └── util │ └── store_wrapper_spec.rb ├── launchdarkly-server-sdk_spec.rb ├── launchdarkly-server-sdk_spec_autoloadtest.rb ├── ldclient_end_to_end_spec.rb ├── ldclient_evaluation_spec.rb ├── ldclient_events_spec.rb ├── ldclient_hooks_spec.rb ├── ldclient_listeners_spec.rb ├── ldclient_migration_variation_spec.rb ├── ldclient_spec.rb ├── migrator_builder_spec.rb ├── mock_components.rb ├── model_builders.rb ├── polling_spec.rb ├── reference_spec.rb ├── requestor_spec.rb ├── segment_store_spec_base.rb ├── simple_lru_cache_spec.rb ├── spec_helper.rb ├── store_spec.rb ├── stream_spec.rb ├── util_spec.rb └── version_spec.rb /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is this a support request?** 11 | This issue tracker is maintained by LaunchDarkly SDK developers and is intended for feedback on the SDK code. If you're not sure whether the problem you are having is specifically related to the SDK, or to the LaunchDarkly service overall, it may be more appropriate to contact the LaunchDarkly support team; they can help to investigate the problem and will consult the SDK team if necessary. You can submit a support request by going [here](https://support.launchdarkly.com/hc/en-us/requests/new) or by emailing support@launchdarkly.com. 12 | 13 | Note that issues filed on this issue tracker are publicly accessible. Do not provide any private account information on your issues. If your problem is specific to your account, you should submit a support request as described above. 14 | 15 | **Describe the bug** 16 | A clear and concise description of what the bug is. 17 | 18 | **To reproduce** 19 | Steps to reproduce the behavior. 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Logs** 25 | If applicable, add any log output related to your problem. 26 | 27 | **SDK version** 28 | The version of this SDK that you are using. 29 | 30 | **Language version, developer tools** 31 | For instance, Go 1.11 or Ruby 2.5.3. If you are using a language that requires a separate compiler, such as C, please include the name and version of the compiler too. 32 | 33 | **OS/platform** 34 | For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Support request 4 | url: https://support.launchdarkly.com/hc/en-us/requests/new 5 | about: File your support requests with LaunchDarkly's support team 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I would love to see the SDK [...does something new...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/actions/build-docs/action.yml: -------------------------------------------------------------------------------- 1 | name: Build Documentation 2 | description: 'Build Documentation.' 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Build Documentation 8 | shell: bash 9 | run: cd docs && make html 10 | -------------------------------------------------------------------------------- /.github/actions/check/action.yml: -------------------------------------------------------------------------------- 1 | name: Quality control checks 2 | description: 'Runs tests, linters, and contract tests' 3 | inputs: 4 | flaky: 5 | description: 'Is the platform under test considered flaky?' 6 | required: false 7 | default: 'false' 8 | token: 9 | description: 'GH token used to fetch the SDK test harness' 10 | required: true 11 | 12 | runs: 13 | using: composite 14 | steps: 15 | - name: Skip flaky tests for jruby 16 | if: ${{ inputs.flaky == 'true' }} 17 | shell: bash 18 | run: echo "SPEC_TAGS=-t '~flaky'" >> $GITHUB_ENV 19 | 20 | - name: Run tests 21 | shell: bash 22 | run: bundle _2.2.33_ exec rspec spec $SPEC_TAGS 23 | 24 | - name: Run RuboCop 25 | shell: bash 26 | run: bundle exec rubocop --parallel 27 | 28 | - name: Build contract tests 29 | if: ${{ inputs.flaky != 'true' }} 30 | shell: bash 31 | run: make build-contract-tests 32 | 33 | - name: Start contract test service 34 | if: ${{ inputs.flaky != 'true' }} 35 | shell: bash 36 | run: make start-contract-test-service-bg 37 | 38 | - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.2.0 39 | if: ${{ inputs.flaky != 'true' }} 40 | with: 41 | test_service_port: 9000 42 | enable_persistence_tests: true 43 | token: ${{ inputs.token }} 44 | -------------------------------------------------------------------------------- /.github/actions/publish-docs/action.yml: -------------------------------------------------------------------------------- 1 | name: Publish Documentation 2 | description: 'Publish the documentation to GitHub Pages' 3 | inputs: 4 | token: 5 | description: 'Token to use for publishing.' 6 | required: true 7 | 8 | runs: 9 | using: composite 10 | steps: 11 | - uses: launchdarkly/gh-actions/actions/publish-pages@publish-pages-v1.0.2 12 | name: 'Publish to Github pages' 13 | with: 14 | docs_path: docs/build/html/ 15 | github_token: ${{ inputs.token }} # For the shared action the token should be a GITHUB_TOKEN< 16 | -------------------------------------------------------------------------------- /.github/actions/publish/action.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | description: 'Publish the package to rubygems' 3 | inputs: 4 | dry_run: 5 | description: 'Is this a dry run. If so no package will be published.' 6 | required: true 7 | outputs: 8 | gem-hash: 9 | description: "base64-encoded sha256 hashes of distribution files" 10 | value: ${{ steps.gem-hash.outputs.gem-hash }} 11 | 12 | runs: 13 | using: composite 14 | steps: 15 | - uses: actions/download-artifact@v4 16 | with: 17 | pattern: 'gems-*' 18 | merge-multiple: true 19 | 20 | - name: Hash gem for provenance 21 | id: gem-hash 22 | shell: bash 23 | run: | 24 | echo "gem-hash=$(sha256sum launchdarkly-server-sdk-*.gem | base64 -w0)" >> "$GITHUB_OUTPUT" 25 | 26 | - name: Publish Library 27 | shell: bash 28 | if: ${{ inputs.dry_run == 'false' }} 29 | run: ls launchdarkly-server-sdk-*.gem | xargs -I {} gem push {} 30 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Ruby 2 | description: 'Install ruby, and optionally the project dependencies' 3 | inputs: 4 | version: 5 | description: 'The version of ruby to setup and run' 6 | required: true 7 | install-dependencies: 8 | description: 'Whether to install the project dependencies' 9 | required: false 10 | default: 'true' 11 | 12 | runs: 13 | using: composite 14 | steps: 15 | - uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ inputs.version }} 18 | bundler: 2.2.33 19 | 20 | - name: Install dependencies 21 | if: ${{ inputs.install-dependencies == 'true' }} 22 | shell: bash 23 | run: bundle _2.2.33_ install 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Requirements** 2 | 3 | - [ ] I have added test coverage for new or changed functionality 4 | - [ ] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) 5 | - [ ] I have validated my changes against all supported platform versions 6 | 7 | **Related issues** 8 | 9 | Provide links to any issues in this repository or elsewhere relating to this pull request. 10 | 11 | **Describe the solution you've provided** 12 | 13 | Provide a clear and concise description of what you expect to happen. 14 | 15 | **Describe alternatives you've considered** 16 | 17 | Provide a clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | 21 | Add any other context about the pull request here. 22 | -------------------------------------------------------------------------------- /.github/workflows/build-gem.yml: -------------------------------------------------------------------------------- 1 | name: Build gem 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | version: 7 | description: 'The version of ruby to build against' 8 | type: string 9 | default: '3.0' 10 | upload-artifact: 11 | description: 'Whether to upload the gem as an artifact' 12 | type: boolean 13 | required: false 14 | default: true 15 | 16 | jobs: 17 | build-gem: 18 | runs-on: ubuntu-latest 19 | 20 | env: 21 | LD_SKIP_DATABASE_TESTS: 0 22 | BUILD_PLATFORM: ${{ startsWith(inputs.version, 'jruby') && 'jruby' || 'ruby' }} 23 | FLAKY: ${{ startsWith(inputs.version, 'jruby') && 'true' || 'false' }} 24 | 25 | services: 26 | redis: 27 | image: redis 28 | ports: 29 | - 6379:6379 30 | dynamodb: 31 | image: amazon/dynamodb-local 32 | ports: 33 | - 8000:8000 34 | consul: 35 | image: hashicorp/consul 36 | ports: 37 | - 8500:8500 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | 42 | - uses: ./.github/actions/setup 43 | with: 44 | version: ${{ inputs.version }} 45 | 46 | - uses: ./.github/actions/check 47 | with: 48 | flaky: ${{ env.FLAKY }} 49 | token: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Build gemspec 52 | run: gem build launchdarkly-server-sdk.gemspec --platform=$BUILD_PLATFORM 53 | 54 | - uses: actions/upload-artifact@v4 55 | if: ${{ inputs.upload-artifact }} 56 | with: 57 | name: gems-${{ inputs.version }} 58 | path: launchdarkly-server-sdk-*.gem 59 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run CI 2 | on: 3 | push: 4 | branches: [ main, 'feat/**' ] 5 | paths-ignore: 6 | - '**.md' # Do not need to run CI for markdown changes. 7 | pull_request: 8 | branches: [ main, 'feat/**' ] 9 | paths-ignore: 10 | - '**.md' 11 | 12 | jobs: 13 | build-linux-oldest: 14 | uses: ./.github/workflows/build-gem.yml 15 | with: 16 | version: '3.0' 17 | 18 | build-linux-latest: 19 | uses: ./.github/workflows/build-gem.yml 20 | with: 21 | version: '3.2' 22 | 23 | build-linux-jruby: 24 | uses: ./.github/workflows/build-gem.yml 25 | with: 26 | version: 'jruby-9.4' 27 | 28 | build-docs: 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | 34 | - uses: ./.github/actions/setup 35 | with: 36 | version: '3.0' 37 | 38 | - uses: ./.github/actions/build-docs 39 | 40 | build-windows: 41 | runs-on: windows-latest 42 | 43 | env: 44 | LD_SKIP_DATABASE_TESTS: 1 45 | 46 | defaults: 47 | run: 48 | shell: powershell 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - uses: ./.github/actions/setup 54 | with: 55 | version: '3.0' 56 | 57 | - name: Run tests 58 | run: bundle _2.2.33_ exec rspec spec 59 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr-title.yml: -------------------------------------------------------------------------------- 1 | name: Lint PR title 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | lint-pr-title: 12 | uses: launchdarkly/gh-actions/.github/workflows/lint-pr-title.yml@main 13 | -------------------------------------------------------------------------------- /.github/workflows/manual-publish-docs.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | name: Publish Documentation 5 | jobs: 6 | build-publish-docs: 7 | runs-on: ubuntu-latest 8 | 9 | permissions: 10 | contents: write # Needed in this case to write github pages. 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: ./.github/actions/setup 16 | with: 17 | version: '3.0' 18 | install-dependencies: false 19 | 20 | - uses: ./.github/actions/build-docs 21 | 22 | - uses: ./.github/actions/publish-docs 23 | with: 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/manual-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | dry_run: 6 | description: 'Is this a dry run. If so no package will be published.' 7 | type: boolean 8 | required: true 9 | 10 | jobs: 11 | build-ruby-gem: 12 | uses: ./.github/workflows/build-gem.yml 13 | with: 14 | version: '3.0' 15 | 16 | build-jruby-gem: 17 | uses: ./.github/workflows/build-gem.yml 18 | with: 19 | version: 'jruby-9.4' 20 | 21 | publish: 22 | runs-on: ubuntu-latest 23 | needs: [ 'build-ruby-gem', 'build-jruby-gem' ] 24 | 25 | outputs: 26 | gem-hash: ${{ steps.publish.outputs.gem-hash }} 27 | 28 | permissions: 29 | id-token: write # Needed if using OIDC to get release secrets. 30 | contents: write 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - uses: ./.github/actions/setup 36 | with: 37 | version: '3.0' 38 | install-dependencies: false 39 | 40 | - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 41 | name: 'Get rubygems API key' 42 | if: ${{ !inputs.dry_run }} 43 | with: 44 | aws_assume_role: ${{ vars.AWS_ROLE_ARN }} 45 | ssm_parameter_pairs: '/production/common/releasing/rubygems/api_key = GEM_HOST_API_KEY' 46 | 47 | - uses: ./.github/actions/build-docs 48 | 49 | - uses: ./.github/actions/publish 50 | id: publish 51 | with: 52 | dry_run: ${{ inputs.dry_run }} 53 | 54 | - uses: ./.github/actions/publish-docs 55 | if: ${{ !inputs.dry_run }} 56 | with: 57 | token: ${{secrets.GITHUB_TOKEN}} 58 | 59 | release-provenance: 60 | needs: [ 'publish' ] 61 | 62 | permissions: 63 | actions: read 64 | id-token: write 65 | contents: write 66 | 67 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 68 | with: 69 | base64-subjects: "${{ needs.publish.outputs.gem-hash }}" 70 | upload-assets: ${{ !inputs.dry_run }} 71 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Run Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release-package: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write # Contents and pull-requests are for release-please to make releases. 14 | pull-requests: write 15 | 16 | outputs: 17 | release-created: ${{ steps.release.outputs.release_created }} 18 | upload-tag-name: ${{ steps.release.outputs.tag_name }} 19 | 20 | steps: 21 | - uses: googleapis/release-please-action@v4 22 | id: release 23 | 24 | build-ruby-gem: 25 | needs: [ 'release-package' ] 26 | if: ${{ needs.release-package.outputs.release-created == 'true' }} 27 | uses: ./.github/workflows/build-gem.yml 28 | with: 29 | version: '3.0' 30 | 31 | build-jruby-gem: 32 | needs: [ 'release-package' ] 33 | if: ${{ needs.release-package.outputs.release-created == 'true' }} 34 | uses: ./.github/workflows/build-gem.yml 35 | with: 36 | version: 'jruby-9.4' 37 | 38 | publish: 39 | runs-on: ubuntu-latest 40 | needs: [ 'release-package', 'build-ruby-gem', 'build-jruby-gem' ] 41 | if: ${{ needs.release-package.outputs.release-created == 'true' }} 42 | 43 | outputs: 44 | gem-hash: ${{ steps.publish.outputs.gem-hash }} 45 | 46 | permissions: 47 | id-token: write # Needed if using OIDC to get release secrets. 48 | contents: write # Contents and pull-requests are for release-please to make releases. 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - uses: ./.github/actions/setup 54 | with: 55 | version: '3.0' 56 | install-dependencies: false 57 | 58 | - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.2.0 59 | name: 'Get rubygems API key' 60 | with: 61 | aws_assume_role: ${{ vars.AWS_ROLE_ARN }} 62 | ssm_parameter_pairs: '/production/common/releasing/rubygems/api_key = GEM_HOST_API_KEY' 63 | 64 | - uses: ./.github/actions/build-docs 65 | 66 | - uses: ./.github/actions/publish 67 | id: publish 68 | with: 69 | dry_run: false 70 | 71 | - uses: ./.github/actions/publish-docs 72 | with: 73 | token: ${{ secrets.GITHUB_TOKEN }} 74 | 75 | release-provenance: 76 | needs: [ 'release-package', 'publish' ] 77 | if: ${{ needs.release-package.outputs.release-created == 'true' }} 78 | 79 | permissions: 80 | actions: read 81 | id-token: write 82 | contents: write 83 | 84 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 85 | with: 86 | base64-subjects: "${{ needs.publish.outputs.gem-hash }}" 87 | upload-assets: true 88 | upload-tag-name: ${{ needs.release-package.outputs.upload-tag-name }} 89 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | # Happen once per day at 1:30 AM 6 | - cron: '30 1 * * *' 7 | 8 | jobs: 9 | sdk-close-stale: 10 | uses: launchdarkly/gh-actions/.github/workflows/sdk-stale.yml@main 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /docs/build 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.bundle 10 | *.so 11 | *.o 12 | *.a 13 | mkmf.log 14 | *.gem 15 | .DS_Store 16 | Gemfile.lock 17 | .ruby-version 18 | contract-tests/contract-tests.iml 19 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "8.10.0" 3 | } 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --order random 3 | -------------------------------------------------------------------------------- /.sdk_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "sdks": { 4 | "ruby-server-sdk": { 5 | "name": "Ruby Server SDK", 6 | "type": "server-side", 7 | "languages": [ 8 | "Ruby" 9 | ], 10 | "userAgents": ["RubyClient"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | SimpleCov.start do 2 | add_filter '/spec/' 3 | add_filter '/.bundle/' 4 | end 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Repository Maintainers 2 | * @launchdarkly/team-sdk-ruby 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the LaunchDarkly Server-side SDK for Ruby 2 | 3 | LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/sdk/concepts/contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this SDK. 4 | 5 | ## Submitting bug reports and feature requests 6 | 7 | The LaunchDarkly SDK team monitors the [issue tracker](https://github.com/launchdarkly/ruby-server-sdk/issues) in the SDK repository. Bug reports and feature requests specific to this SDK should be filed in this issue tracker. The SDK team will respond to all newly filed issues within two business days. 8 | 9 | ## Submitting pull requests 10 | 11 | We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. 12 | 13 | ## Build instructions 14 | 15 | ### Prerequisites 16 | 17 | This SDK is built with [Bundler](https://bundler.io/). To install Bundler, run `gem install bundler`. You might need `sudo` to execute the command successfully. 18 | 19 | To install the runtime dependencies: 20 | 21 | ``` 22 | bundle install 23 | ``` 24 | 25 | ### Testing 26 | 27 | To run all unit tests: 28 | 29 | ``` 30 | bundle exec rspec spec 31 | ``` 32 | 33 | The full unit test suite includes live tests of the integrations for Consul, DynamoDB, and Redis. Those tests expect you to have instances of all of those databases running locally. By default, these tests will be skipped. To run them, set the environment variable `LD_SKIP_DATABASE_TESTS=0` before running the tests. 34 | 35 | ### Building documentation 36 | 37 | Documentation is built automatically with YARD for each release. To build the documentation locally: 38 | 39 | ``` 40 | cd docs 41 | make 42 | ``` 43 | 44 | The output will appear in `docs/build/html`. 45 | 46 | ## Code organization 47 | 48 | The SDK's namespacing convention is as follows: 49 | 50 | * `LaunchDarkly`: This namespace contains the most commonly used classes and methods in the SDK, such as `LDClient` and `EvaluationDetail`. 51 | * `LaunchDarkly::Integrations`: This namespace contains entry points for optional features that are related to how the SDK communicates with other systems, such as `Redis`. 52 | * `LaunchDarkly::Interfaces`: This namespace contains types that do not do anything by themselves, but may need to be referenced if you are using optional features or implementing a custom component. 53 | 54 | A special case is the namespace `LaunchDarkly::Impl`, and any namespaces within it. Everything under `Impl` is considered a private implementation detail: all files there are excluded from the generated documentation, and are considered subject to change at any time and not supported for direct use by application developers. We do this because Ruby's scope/visibility system is somewhat limited compared to other languages: a method can be `private` or `protected` within a class, but there is no way to make it visible to other classes in the SDK yet invisible to code outside of the SDK, and there is similarly no way to hide a class. 55 | 56 | So, if there is a class whose existence is entirely an implementation detail, it should be in `Impl`. Similarly, classes that are _not_ in `Impl` must not expose any public members that are not meant to be part of the supported public API. This is important because of our guarantee of backward compatibility for all public APIs within a major version: we want to be able to change our implementation details to suit the needs of the code, without worrying about breaking a customer's code. Due to how the language works, we can't actually prevent an application developer from referencing those classes in their code, but this convention makes it clear that such use is discouraged and unsupported. 57 | 58 | ## Documenting types and methods 59 | 60 | All classes and public methods outside of `LaunchDarkly::Impl` should have documentation comments. These are used to build the API documentation that is published at https://launchdarkly.github.io/ruby-server-sdk/ and https://www.rubydoc.info/gems/launchdarkly-server-sdk. The documentation generator is YARD; see https://yardoc.org/ for the comment format it uses. 61 | 62 | Please try to make the style and terminology in documentation comments consistent with other documentation comments in the SDK. Also, if a class or method is being added that has an equivalent in other SDKs, and if we have described it in a consistent away in those other SDKs, please reuse the text whenever possible (with adjustments for anything language-specific) rather than writing new text. 63 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2014 Catamorphic, Co. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEMP_TEST_OUTPUT=/tmp/contract-test-service.log 2 | 3 | # TEST_HARNESS_PARAMS can be set to add -skip parameters for any contract tests that cannot yet pass 4 | # Explanation of current skips: 5 | TEST_HARNESS_PARAMS= 6 | 7 | build-contract-tests: 8 | @cd contract-tests && bundle _2.2.33_ install 9 | 10 | start-contract-test-service: 11 | @cd contract-tests && bundle _2.2.33_ exec ruby service.rb 12 | 13 | start-contract-test-service-bg: 14 | @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" 15 | @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & 16 | 17 | run-contract-tests: 18 | @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \ 19 | | VERSION=v2 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh 20 | 21 | contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests 22 | 23 | .PHONY: build-contract-tests start-contract-test-service run-contract-tests contract-tests 24 | -------------------------------------------------------------------------------- /PROVENANCE.md: -------------------------------------------------------------------------------- 1 | ## Verifying SDK build provenance with the SLSA framework 2 | 3 | LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published SDK packages. 4 | 5 | As part of [SLSA requirements for level 3 compliance](https://slsa.dev/spec/v1.0/requirements), LaunchDarkly publishes provenance about our SDK package builds using [GitHub's generic SLSA3 provenance generator](https://github.com/slsa-framework/slsa-github-generator/blob/main/internal/builders/generic/README.md#generation-of-slsa3-provenance-for-arbitrary-projects) for distribution alongside our packages. These attestations are available for download from the GitHub release page for the release version under Assets > `multiple-provenance.intoto.jsonl`. 6 | 7 | To verify SLSA provenance attestations, we recommend using [slsa-verifier](https://github.com/slsa-framework/slsa-verifier). Example usage for verifying SDK packages is included below: 8 | 9 | 10 | ``` 11 | # Set the version of the SDK to verify 12 | SDK_VERSION=8.10.0 13 | ``` 14 | 15 | 16 | ``` 17 | # Download gem 18 | $ gem fetch launchdarkly-server-sdk -v $SDK_VERSION 19 | 20 | # Download provenance from Github release 21 | $ curl --location -O \ 22 | https://github.com/launchdarkly/ruby-server-sdk/releases/download/${SDK_VERSION}/launchdarkly-server-sdk-${SDK_VERSION}.gem.intoto.jsonl 23 | 24 | # Run slsa-verifier to verify provenance against package artifacts 25 | $ slsa-verifier verify-artifact \ 26 | --provenance-path launchdarkly-server-sdk-${SDK_VERSION}.gem.intoto.jsonl \ 27 | --source-uri github.com/launchdarkly/ruby-server-sdk \ 28 | launchdarkly-server-sdk-${SDK_VERSION}.gem 29 | ``` 30 | 31 | Below is a sample of expected output. 32 | 33 | ``` 34 | Verified signature against tlog entry index 78214752 at URL: https://rekor.sigstore.dev/api/v1/log/entries/24296fb24b8ad77ab941c118ef7e0b2d656b962a0d670c6ac91cfa37d07b7b121ae560b00a978ecf 35 | Verified build using builder "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v1.7.0" at commit f43b3ad834103fdc282652efbfe4963e8dfa737b 36 | Verifying artifact launchdarkly-server-sdk-8.3.0.gem: PASSED 37 | 38 | PASSED: Verified SLSA provenance 39 | ``` 40 | 41 | Alternatively, to verify the provenance manually, the SLSA framework specifies [recommendations for verifying build artifacts](https://slsa.dev/spec/v1.0/verifying-artifacts) in their documentation. 42 | 43 | **Note:** These instructions do not apply when building our SDKs from source. 44 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting and Fixing Security Issues 2 | 3 | Please report all security issues to the LaunchDarkly security team by submitting a bug bounty report to our [HackerOne program](https://hackerone.com/launchdarkly?type=team). LaunchDarkly will triage and address all valid security issues following the response targets defined in our program policy. Valid security issues may be eligible for a bounty. 4 | 5 | Please do not open issues or pull requests for security issues. This makes the problem immediately visible to everyone, including potentially malicious actors. 6 | -------------------------------------------------------------------------------- /contract-tests/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'launchdarkly-server-sdk', path: '..' 4 | 5 | gem 'http', '~> 5.1' 6 | gem 'json' 7 | gem "puma", "~> 6.6" 8 | gem "rackup", "~> 2.2" 9 | gem 'sinatra', '>= 4.1' 10 | 11 | gem 'rubocop', '~> 1.37', group: 'development' 12 | gem 'rubocop-performance', '~> 1.15', group: 'development' 13 | 14 | gem "connection_pool", "~> 2.4" 15 | gem "redis", "~> 5.3" 16 | 17 | gem "diplomat", "~> 2.6" 18 | 19 | gem "aws-sdk-dynamodb", "~> 1.127" 20 | -------------------------------------------------------------------------------- /contract-tests/README.md: -------------------------------------------------------------------------------- 1 | # SDK contract test service 2 | 3 | This directory contains an implementation of the cross-platform SDK testing protocol defined by https://github.com/launchdarkly/sdk-test-harness. See that project's `README` for details of this protocol, and the kinds of SDK capabilities that are relevant to the contract tests. This code should not need to be updated unless the SDK has added or removed such capabilities. 4 | 5 | To run these tests locally, run `make contract-tests` from the SDK project root directory. This downloads the correct version of the test harness tool automatically. 6 | 7 | Or, to test against an in-progress local version of the test harness, run `make start-contract-test-service` from the SDK project root directory; then, in the root directory of the `sdk-test-harness` project, build the test harness and run it from the command line. 8 | -------------------------------------------------------------------------------- /contract-tests/big_segment_store_fixture.rb: -------------------------------------------------------------------------------- 1 | require 'http' 2 | 3 | class BigSegmentStoreFixture 4 | def initialize(uri) 5 | @uri = uri 6 | end 7 | 8 | def get_metadata 9 | response = HTTP.post("#{@uri}/getMetadata") 10 | json = response.parse(:json) 11 | LaunchDarkly::Interfaces::BigSegmentStoreMetadata.new(json['lastUpToDate']) 12 | end 13 | 14 | def get_membership(context_hash) 15 | response = HTTP.post("#{@uri}/getMembership", :json => {:contextHash => context_hash}) 16 | json = response.parse(:json) 17 | 18 | json['values'] 19 | end 20 | 21 | def stop 22 | HTTP.delete(@uri) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /contract-tests/hook.rb: -------------------------------------------------------------------------------- 1 | require 'ldclient-rb' 2 | 3 | class Hook 4 | include LaunchDarkly::Interfaces::Hooks::Hook 5 | 6 | # 7 | # @param name [String] 8 | # @param callback_uri [String] 9 | # @param data [Hash] 10 | # @parm errors [Hash] 11 | # 12 | def initialize(name, callback_uri, data, errors) 13 | @metadata = LaunchDarkly::Interfaces::Hooks::Metadata.new(name) 14 | @callback_uri = callback_uri 15 | @data = data 16 | @errors = errors 17 | @context_filter = LaunchDarkly::Impl::ContextFilter.new(false, []) 18 | end 19 | 20 | def metadata 21 | @metadata 22 | end 23 | 24 | # 25 | # @param evaluation_series_context [LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext] 26 | # @param data [Hash] 27 | # 28 | def before_evaluation(evaluation_series_context, data) 29 | raise @errors[:beforeEvaluation] if @errors.include? :beforeEvaluation 30 | 31 | payload = { 32 | evaluationSeriesContext: { 33 | flagKey: evaluation_series_context.key, 34 | context: @context_filter.filter(evaluation_series_context.context), 35 | defaultValue: evaluation_series_context.default_value, 36 | method: evaluation_series_context.method, 37 | }, 38 | evaluationSeriesData: data, 39 | stage: 'beforeEvaluation', 40 | } 41 | result = HTTP.post(@callback_uri, json: payload) 42 | 43 | (data || {}).merge(@data[:beforeEvaluation] || {}) 44 | end 45 | 46 | 47 | # 48 | # @param evaluation_series_context [LaunchDarkly::Interfaces::Hooks::EvaluationSeriesContext] 49 | # @param data [Hash] 50 | # @param detail [LaunchDarkly::EvaluationDetail] 51 | # 52 | def after_evaluation(evaluation_series_context, data, detail) 53 | raise @errors[:afterEvaluation] if @errors.include? :afterEvaluation 54 | 55 | payload = { 56 | evaluationSeriesContext: { 57 | flagKey: evaluation_series_context.key, 58 | context: @context_filter.filter(evaluation_series_context.context), 59 | defaultValue: evaluation_series_context.default_value, 60 | method: evaluation_series_context.method, 61 | }, 62 | evaluationSeriesData: data, 63 | evaluationDetail: { 64 | value: detail.value, 65 | variationIndex: detail.variation_index, 66 | reason: detail.reason, 67 | }, 68 | stage: 'afterEvaluation', 69 | } 70 | HTTP.post(@callback_uri, json: payload) 71 | 72 | (data || {}).merge(@data[:afterEvaluation] || {}) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /contract-tests/service.rb: -------------------------------------------------------------------------------- 1 | require 'launchdarkly-server-sdk' 2 | require 'json' 3 | require 'logger' 4 | require 'net/http' 5 | require 'sinatra' 6 | 7 | require './client_entity' 8 | 9 | configure :development do 10 | disable :show_exceptions 11 | end 12 | 13 | $log = Logger.new(STDOUT) 14 | $log.formatter = proc {|severity, datetime, progname, msg| 15 | "[GLOBAL] #{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n" 16 | } 17 | 18 | set :port, 9000 19 | set :logging, false 20 | 21 | clients = {} 22 | clientCounter = 0 23 | 24 | get '/' do 25 | { 26 | capabilities: [ 27 | 'server-side', 28 | 'server-side-polling', 29 | 'big-segments', 30 | 'all-flags-with-reasons', 31 | 'all-flags-client-side-only', 32 | 'all-flags-details-only-for-tracked-flags', 33 | 'filtering', 34 | 'filtering-strict', 35 | 'secure-mode-hash', 36 | 'tags', 37 | 'migrations', 38 | 'event-gzip', 39 | 'optional-event-gzip', 40 | 'event-sampling', 41 | 'context-comparison', 42 | 'polling-gzip', 43 | 'inline-context-all', 44 | 'instance-id', 45 | 'anonymous-redaction', 46 | 'evaluation-hooks', 47 | 'omit-anonymous-contexts', 48 | 'client-prereq-events', 49 | 'persistent-data-store-consul', 50 | 'persistent-data-store-dynamodb', 51 | 'persistent-data-store-redis', 52 | ], 53 | }.to_json 54 | end 55 | 56 | delete '/' do 57 | $log.info("Test service has told us to exit") 58 | Thread.new { sleep 1; exit } 59 | return 204 60 | end 61 | 62 | post '/' do 63 | opts = JSON.parse(request.body.read, :symbolize_names => true) 64 | tag = "[#{opts[:tag]}]" 65 | 66 | clientCounter += 1 67 | clientId = clientCounter.to_s 68 | 69 | log = Logger.new(STDOUT) 70 | log.formatter = proc {|severity, datetime, progname, msg| 71 | "#{tag} #{datetime.strftime('%Y-%m-%d %H:%M:%S.%3N')} #{severity} #{progname} #{msg}\n" 72 | } 73 | 74 | log.info("Starting client") 75 | log.debug("Parameters: #{opts}") 76 | 77 | client = ClientEntity.new(log, opts[:configuration]) 78 | 79 | if !client.initialized? && opts[:configuration][:initCanFail] == false 80 | client.close() 81 | return [500, nil, "Failed to initialize"] 82 | end 83 | 84 | clientResourceUrl = "/clients/#{clientId}" 85 | clients[clientId] = client 86 | return [201, {'Location' => clientResourceUrl}, nil] 87 | end 88 | 89 | post '/clients/:id' do |clientId| 90 | client = clients[clientId] 91 | return 404 if client.nil? 92 | 93 | params = JSON.parse(request.body.read, :symbolize_names => true) 94 | 95 | client.log.info("Processing request for client #{clientId}") 96 | client.log.debug("Parameters: #{params}") 97 | 98 | case params[:command] 99 | when "evaluate" 100 | response = client.evaluate(params[:evaluate]) 101 | return [200, nil, response.to_json] 102 | when "evaluateAll" 103 | response = {:state => client.evaluate_all(params[:evaluateAll])} 104 | return [200, nil, response.to_json] 105 | when "secureModeHash" 106 | response = {:result => client.secure_mode_hash(params[:secureModeHash])} 107 | return [200, nil, response.to_json] 108 | when "customEvent" 109 | client.track(params[:customEvent]) 110 | return 201 111 | when "identifyEvent" 112 | client.identify(params[:identifyEvent]) 113 | return 201 114 | when "flushEvents" 115 | client.flush_events 116 | return 201 117 | when "getBigSegmentStoreStatus" 118 | status = client.get_big_segment_store_status 119 | return [200, nil, status.to_json] 120 | when "migrationVariation" 121 | response = {:result => client.migration_variation(params[:migrationVariation]).to_s} 122 | return [200, nil, response.to_json] 123 | when "migrationOperation" 124 | response = {:result => client.migration_operation(params[:migrationOperation]).to_s} 125 | return [200, nil, response.to_json] 126 | when "contextComparison" 127 | response = {:equals => client.context_comparison(params[:contextComparison])} 128 | return [200, nil, response.to_json] 129 | end 130 | 131 | return [400, nil, {:error => "Unknown command requested"}.to_json] 132 | end 133 | 134 | delete '/clients/:id' do |clientId| 135 | client = clients[clientId] 136 | return 404 if client.nil? 137 | clients.delete(clientId) 138 | client.close 139 | 140 | return 204 141 | end 142 | 143 | error do 144 | env['sinatra.error'].message 145 | end 146 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | ifeq ($(LD_RELEASE_VERSION),) 2 | TITLE=LaunchDarkly Ruby SDK 3 | else 4 | TITLE=LaunchDarkly Ruby SDK ($(LD_RELEASE_VERSION)) 5 | endif 6 | 7 | .PHONY: dependencies html 8 | 9 | html: dependencies 10 | rm -rf ./build 11 | cd .. && yard doc \ 12 | -o docs/build/html \ 13 | --title "$(TITLE)" \ 14 | --no-private \ 15 | --markup markdown \ 16 | --embed-mixins \ 17 | -r docs/index.md \ 18 | lib/*.rb \ 19 | lib/**/*.rb \ 20 | lib/**/**/*.rb \ 21 | lib/**/**/**/*.rb 22 | rm -f build/html/frames.html 23 | 24 | dependencies: 25 | gem install --conservative yard 26 | gem install --conservative redcarpet # provides Markdown formatting 27 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # LaunchDarkly Server-side SDK for Ruby 2 | 3 | This generated API documentation lists all types and methods in the SDK. 4 | 5 | The API documentation for the most recent SDK release is hosted on [GitHub Pages](https://launchdarkly.github.io/ruby-server-sdk). API documentation for current and past releases is hosted on [RubyDoc.info](https://www.rubydoc.info/gems/launchdarkly-server-sdk). 6 | 7 | Source code and readme: [GitHub](https://github.com/launchdarkly/ruby-server-sdk) 8 | 9 | SDK reference guide: [docs.launchdarkly.com](https://docs.launchdarkly.com/sdk/server-side/ruby) 10 | -------------------------------------------------------------------------------- /launchdarkly-server-sdk.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "ldclient-rb/version" 6 | 7 | # rubocop:disable Metrics/BlockLength 8 | Gem::Specification.new do |spec| 9 | spec.name = "launchdarkly-server-sdk" 10 | spec.version = LaunchDarkly::VERSION 11 | spec.authors = ["LaunchDarkly"] 12 | spec.email = ["team@launchdarkly.com"] 13 | spec.summary = "LaunchDarkly SDK for Ruby" 14 | spec.description = "Official LaunchDarkly SDK for Ruby" 15 | spec.homepage = "https://github.com/launchdarkly/ruby-server-sdk" 16 | spec.license = "Apache-2.0" 17 | 18 | spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE.txt"] 19 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | spec.required_ruby_version = ">= 3.0.0" 22 | 23 | spec.add_development_dependency "aws-sdk-dynamodb", "~> 1.57" 24 | spec.add_development_dependency "rexml", "~> 3.3", ">= 3.3.7" 25 | spec.add_development_dependency "bundler", "2.2.33" 26 | spec.add_development_dependency "simplecov", "~> 0.21" 27 | spec.add_development_dependency "rspec", "~> 3.10" 28 | spec.add_development_dependency "diplomat", "~> 2.6" 29 | spec.add_development_dependency "redis", "~> 5.0" 30 | spec.add_development_dependency "connection_pool", "~> 2.3" 31 | spec.add_development_dependency "rspec_junit_formatter", "~> 0.4" 32 | spec.add_development_dependency "timecop", "~> 0.9" 33 | spec.add_development_dependency "listen", "~> 3.3" # see file_data_source.rb 34 | spec.add_development_dependency "webrick", "~> 1.7" 35 | spec.add_development_dependency "rubocop", "~> 1.37" 36 | spec.add_development_dependency "rubocop-performance", "~> 1.15" 37 | 38 | spec.add_runtime_dependency "semantic", "~> 1.6" 39 | spec.add_runtime_dependency "concurrent-ruby", "~> 1.1" 40 | spec.add_runtime_dependency "ld-eventsource", "2.2.3" 41 | spec.add_runtime_dependency "observer", "~> 0.1.2" 42 | spec.add_runtime_dependency "zlib", "~> 3.1" unless RUBY_PLATFORM == "java" 43 | # Please keep ld-eventsource dependency as an exact version so that bugfixes to 44 | # that LD library are always associated with a new SDK version. 45 | 46 | spec.add_runtime_dependency "json", "~> 2.3" 47 | spec.add_runtime_dependency "http", ">= 4.4.0", "< 6.0.0" 48 | end 49 | -------------------------------------------------------------------------------- /lib/launchdarkly-server-sdk.rb: -------------------------------------------------------------------------------- 1 | require_relative "ldclient-rb" 2 | -------------------------------------------------------------------------------- /lib/ldclient-rb.rb: -------------------------------------------------------------------------------- 1 | 2 | # 3 | # Namespace for the LaunchDarkly Ruby SDK. 4 | # 5 | module LaunchDarkly 6 | end 7 | 8 | require "ldclient-rb/version" 9 | require "ldclient-rb/interfaces" 10 | require "ldclient-rb/util" 11 | require "ldclient-rb/flags_state" 12 | require "ldclient-rb/migrations" 13 | require "ldclient-rb/ldclient" 14 | require "ldclient-rb/cache_store" 15 | require "ldclient-rb/expiring_cache" 16 | require "ldclient-rb/memoized_value" 17 | require "ldclient-rb/in_memory_store" 18 | require "ldclient-rb/config" 19 | require "ldclient-rb/context" 20 | require "ldclient-rb/reference" 21 | require "ldclient-rb/stream" 22 | require "ldclient-rb/polling" 23 | require "ldclient-rb/simple_lru_cache" 24 | require "ldclient-rb/non_blocking_thread_pool" 25 | require "ldclient-rb/events" 26 | require "ldclient-rb/requestor" 27 | require "ldclient-rb/integrations" 28 | -------------------------------------------------------------------------------- /lib/ldclient-rb/cache_store.rb: -------------------------------------------------------------------------------- 1 | require "concurrent/map" 2 | 3 | module LaunchDarkly 4 | # 5 | # A thread-safe in-memory store that uses the same semantics that Faraday would expect, although we 6 | # no longer use Faraday. This is used by Requestor, when we are not in a Rails environment. 7 | # 8 | # @private 9 | # 10 | class ThreadSafeMemoryStore 11 | # 12 | # Default constructor 13 | # 14 | # @return [ThreadSafeMemoryStore] a new store 15 | def initialize 16 | @cache = Concurrent::Map.new 17 | end 18 | 19 | # 20 | # Read a value from the cache 21 | # @param key [Object] the cache key 22 | # 23 | # @return [Object] the cache value 24 | def read(key) 25 | @cache[key] 26 | end 27 | 28 | # 29 | # Store a value in the cache 30 | # @param key [Object] the cache key 31 | # @param value [Object] the value to associate with the key 32 | # 33 | # @return [Object] the value 34 | def write(key, value) 35 | @cache[key] = value 36 | end 37 | 38 | # 39 | # Delete a value in the cache 40 | # @param key [Object] the cache key 41 | def delete(key) 42 | @cache.delete(key) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ldclient-rb/expiring_cache.rb: -------------------------------------------------------------------------------- 1 | 2 | module LaunchDarkly 3 | # A thread-safe cache with maximum number of entries and TTL. 4 | # Adapted from https://github.com/SamSaffron/lru_redux/blob/master/lib/lru_redux/ttl/cache.rb 5 | # under MIT license with the following changes: 6 | # * made thread-safe 7 | # * removed many unused methods 8 | # * reading a key does not reset its expiration time, only writing 9 | # @private 10 | class ExpiringCache 11 | def initialize(max_size, ttl) 12 | @max_size = max_size 13 | @ttl = ttl 14 | @data_lru = {} 15 | @data_ttl = {} 16 | @lock = Mutex.new 17 | end 18 | 19 | def [](key) 20 | @lock.synchronize do 21 | ttl_evict 22 | @data_lru[key] 23 | end 24 | end 25 | 26 | def []=(key, val) 27 | @lock.synchronize do 28 | ttl_evict 29 | 30 | @data_lru.delete(key) 31 | @data_ttl.delete(key) 32 | 33 | @data_lru[key] = val 34 | @data_ttl[key] = Time.now.to_f 35 | 36 | if @data_lru.size > @max_size 37 | key, _ = @data_lru.first # hashes have a FIFO ordering in Ruby 38 | 39 | @data_ttl.delete(key) 40 | @data_lru.delete(key) 41 | end 42 | 43 | val 44 | end 45 | end 46 | 47 | def delete(key) 48 | @lock.synchronize do 49 | ttl_evict 50 | 51 | @data_lru.delete(key) 52 | @data_ttl.delete(key) 53 | end 54 | end 55 | 56 | def clear 57 | @lock.synchronize do 58 | @data_lru.clear 59 | @data_ttl.clear 60 | end 61 | end 62 | 63 | private 64 | 65 | def ttl_evict 66 | ttl_horizon = Time.now.to_f - @ttl 67 | key, time = @data_ttl.first 68 | 69 | until time.nil? || time > ttl_horizon 70 | @data_ttl.delete(key) 71 | @data_lru.delete(key) 72 | 73 | key, time = @data_ttl.first 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/ldclient-rb/flags_state.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module LaunchDarkly 4 | # 5 | # A snapshot of the state of all feature flags with regard to a specific context, generated by 6 | # calling the {LDClient#all_flags_state}. Serializing this object to JSON using 7 | # `JSON.generate` (or the `to_json` method) will produce the appropriate data structure for 8 | # bootstrapping the LaunchDarkly JavaScript client. 9 | # 10 | class FeatureFlagsState 11 | def initialize(valid) 12 | @flag_values = {} 13 | @flag_metadata = {} 14 | @valid = valid 15 | end 16 | 17 | # Used internally to build the state map. 18 | # @private 19 | def add_flag(flag_state, with_reasons, details_only_if_tracked) 20 | key = flag_state[:key] 21 | @flag_values[key] = flag_state[:value] 22 | meta = {} 23 | 24 | omit_details = false 25 | if details_only_if_tracked 26 | if !flag_state[:trackEvents] && !flag_state[:trackReason] && !(flag_state[:debugEventsUntilDate] && flag_state[:debugEventsUntilDate] > Impl::Util::current_time_millis) 27 | omit_details = true 28 | end 29 | end 30 | 31 | reason = (!with_reasons and !flag_state[:trackReason]) ? nil : flag_state[:reason] 32 | 33 | if !reason.nil? && !omit_details 34 | meta[:reason] = reason 35 | end 36 | 37 | unless omit_details 38 | meta[:version] = flag_state[:version] 39 | end 40 | 41 | meta[:prerequisites] = flag_state[:prerequisites] unless flag_state[:prerequisites].nil? || flag_state[:prerequisites].empty? 42 | meta[:variation] = flag_state[:variation] unless flag_state[:variation].nil? 43 | meta[:trackEvents] = true if flag_state[:trackEvents] 44 | meta[:trackReason] = true if flag_state[:trackReason] 45 | meta[:debugEventsUntilDate] = flag_state[:debugEventsUntilDate] if flag_state[:debugEventsUntilDate] 46 | @flag_metadata[key] = meta 47 | end 48 | 49 | # Returns true if this object contains a valid snapshot of feature flag state, or false if the 50 | # state could not be computed (for instance, because the client was offline or there was no context). 51 | def valid? 52 | @valid 53 | end 54 | 55 | # Returns the value of an individual feature flag at the time the state was recorded. 56 | # Returns nil if the flag returned the default value, or if there was no such flag. 57 | def flag_value(key) 58 | @flag_values[key] 59 | end 60 | 61 | # Returns a map of flag keys to flag values. If a flag would have evaluated to the default value, 62 | # its value will be nil. 63 | # 64 | # Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client. 65 | # Instead, use as_json. 66 | def values_map 67 | @flag_values 68 | end 69 | 70 | # Returns a hash that can be used as a JSON representation of the entire state map, in the format 71 | # used by the LaunchDarkly JavaScript SDK. Use this method if you are passing data to the front end 72 | # in order to "bootstrap" the JavaScript client. 73 | # 74 | # Do not rely on the exact shape of this data, as it may change in future to support the needs of 75 | # the JavaScript client. 76 | def as_json(*) # parameter is unused, but may be passed if we're using the json gem 77 | ret = @flag_values.clone 78 | ret['$flagsState'] = @flag_metadata 79 | ret['$valid'] = @valid 80 | ret 81 | end 82 | 83 | # Same as as_json, but converts the JSON structure into a string. 84 | def to_json(*a) 85 | as_json.to_json(*a) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl.rb: -------------------------------------------------------------------------------- 1 | 2 | module LaunchDarkly 3 | # 4 | # Internal implementation classes. Everything in this module should be considered unsupported 5 | # and subject to change. 6 | # 7 | # @since 5.5.0 8 | # @private 9 | # 10 | module Impl 11 | # code is in ldclient-rb/impl/ 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/big_segments.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/config" 2 | require "ldclient-rb/expiring_cache" 3 | require "ldclient-rb/impl/repeating_task" 4 | require "ldclient-rb/interfaces" 5 | require "ldclient-rb/util" 6 | 7 | require "digest" 8 | 9 | module LaunchDarkly 10 | module Impl 11 | BigSegmentMembershipResult = Struct.new(:membership, :status) 12 | 13 | class BigSegmentStoreManager 14 | # use this as a singleton whenever a membership query returns nil; it's safe to reuse it because 15 | # we will never modify the membership properties after they're queried 16 | EMPTY_MEMBERSHIP = {} 17 | 18 | def initialize(big_segments_config, logger) 19 | @store = big_segments_config.store 20 | @stale_after_millis = big_segments_config.stale_after * 1000 21 | @status_provider = BigSegmentStoreStatusProviderImpl.new(-> { get_status }) 22 | @logger = logger 23 | @last_status = nil 24 | 25 | unless @store.nil? 26 | @cache = ExpiringCache.new(big_segments_config.context_cache_size, big_segments_config.context_cache_time) 27 | @poll_worker = RepeatingTask.new(big_segments_config.status_poll_interval, 0, -> { poll_store_and_update_status }, logger, 'LD/BigSegments#status') 28 | @poll_worker.start 29 | end 30 | end 31 | 32 | attr_reader :status_provider 33 | 34 | def stop 35 | @poll_worker.stop unless @poll_worker.nil? 36 | @store.stop unless @store.nil? 37 | end 38 | 39 | def get_context_membership(context_key) 40 | return nil unless @store 41 | membership = @cache[context_key] 42 | unless membership 43 | begin 44 | membership = @store.get_membership(BigSegmentStoreManager.hash_for_context_key(context_key)) 45 | membership = EMPTY_MEMBERSHIP if membership.nil? 46 | @cache[context_key] = membership 47 | rescue => e 48 | LaunchDarkly::Util.log_exception(@logger, "Big Segment store membership query returned error", e) 49 | return BigSegmentMembershipResult.new(nil, BigSegmentsStatus::STORE_ERROR) 50 | end 51 | end 52 | poll_store_and_update_status unless @last_status 53 | unless @last_status.available 54 | return BigSegmentMembershipResult.new(membership, BigSegmentsStatus::STORE_ERROR) 55 | end 56 | BigSegmentMembershipResult.new(membership, @last_status.stale ? BigSegmentsStatus::STALE : BigSegmentsStatus::HEALTHY) 57 | end 58 | 59 | def get_status 60 | @last_status || poll_store_and_update_status 61 | end 62 | 63 | def poll_store_and_update_status 64 | new_status = Interfaces::BigSegmentStoreStatus.new(false, false) # default to "unavailable" if we don't get a new status below 65 | unless @store.nil? 66 | begin 67 | metadata = @store.get_metadata 68 | new_status = Interfaces::BigSegmentStoreStatus.new(true, !metadata || stale?(metadata.last_up_to_date)) 69 | rescue => e 70 | LaunchDarkly::Util.log_exception(@logger, "Big Segment store status query returned error", e) 71 | end 72 | end 73 | @last_status = new_status 74 | @status_provider.update_status(new_status) 75 | 76 | new_status 77 | end 78 | 79 | def stale?(timestamp) 80 | !timestamp || ((Impl::Util.current_time_millis - timestamp) >= @stale_after_millis) 81 | end 82 | 83 | def self.hash_for_context_key(context_key) 84 | Digest::SHA256.base64digest(context_key) 85 | end 86 | end 87 | 88 | # 89 | # Default implementation of the BigSegmentStoreStatusProvider interface. 90 | # 91 | # There isn't much to this because the real implementation is in BigSegmentStoreManager - we pass in a lambda 92 | # that allows us to get the current status from that class. Also, the standard Observer methods such as 93 | # add_observer are provided for us because BigSegmentStoreStatusProvider mixes in Observer, so all we need to 94 | # to do make notifications happen is to call the Observer methods "changed" and "notify_observers". 95 | # 96 | class BigSegmentStoreStatusProviderImpl 97 | include LaunchDarkly::Interfaces::BigSegmentStoreStatusProvider 98 | 99 | def initialize(status_fn) 100 | @status_fn = status_fn 101 | @last_status = nil 102 | end 103 | 104 | def status 105 | @status_fn.call 106 | end 107 | 108 | def update_status(new_status) 109 | if !@last_status || new_status != @last_status 110 | @last_status = new_status 111 | changed 112 | notify_observers(new_status) 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/broadcaster.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LaunchDarkly 4 | module Impl 5 | 6 | # 7 | # A generic mechanism for registering event listeners and broadcasting 8 | # events to them. 9 | # 10 | # The SDK maintains an instance of this for each available type of listener 11 | # (flag change, data store status, etc.). They are all intended to share a 12 | # single executor service; notifications are submitted individually to this 13 | # service for each listener. 14 | # 15 | class Broadcaster 16 | def initialize(executor, logger) 17 | @listeners = Concurrent::Set.new 18 | @executor = executor 19 | @logger = logger 20 | end 21 | 22 | # 23 | # Register a listener to this broadcaster. 24 | # 25 | # @param listener [#update] 26 | # 27 | def add_listener(listener) 28 | unless listener.respond_to? :update 29 | logger.warn("listener (#{listener.class}) does not respond to :update method. ignoring as registered listener") 30 | return 31 | end 32 | 33 | listeners.add(listener) 34 | end 35 | 36 | # 37 | # Removes a registered listener from this broadcaster. 38 | # 39 | def remove_listener(listener) 40 | listeners.delete(listener) 41 | end 42 | 43 | def has_listeners? 44 | !listeners.empty? 45 | end 46 | 47 | # 48 | # Broadcast the provided event to all registered listeners. 49 | # 50 | # Each listener will be notified using the broadcasters executor. This 51 | # method is non-blocking. 52 | # 53 | def broadcast(event) 54 | listeners.each do |listener| 55 | executor.post do 56 | begin 57 | listener.update(event) 58 | rescue StandardError => e 59 | logger.error("listener (#{listener.class}) raised exception (#{e}) processing event (#{event.class})") 60 | end 61 | end 62 | end 63 | end 64 | 65 | 66 | private 67 | 68 | # @return [Concurrent::ThreadPoolExecutor] 69 | attr_reader :executor 70 | 71 | # @return [Logger] 72 | attr_reader :logger 73 | 74 | # @return [Concurrent::Set] 75 | attr_reader :listeners 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/context.rb: -------------------------------------------------------------------------------- 1 | require "erb" 2 | 3 | module LaunchDarkly 4 | module Impl 5 | module Context 6 | ERR_KIND_NON_STRING = 'context kind must be a string' 7 | ERR_KIND_CANNOT_BE_KIND = '"kind" is not a valid context kind' 8 | ERR_KIND_CANNOT_BE_MULTI = '"multi" is not a valid context kind' 9 | ERR_KIND_INVALID_CHARS = 'context kind contains disallowed characters' 10 | 11 | ERR_KEY_NON_STRING = 'context key must be a string' 12 | ERR_KEY_EMPTY = 'context key must not be empty' 13 | 14 | ERR_NAME_NON_STRING = 'context name must be a string' 15 | 16 | ERR_ANONYMOUS_NON_BOOLEAN = 'context anonymous must be a boolean' 17 | 18 | # 19 | # We allow consumers of this SDK to provide us with either a Hash or an 20 | # instance of an LDContext. This is convenient for them but not as much 21 | # for us. To make the conversion slightly more convenient for us, we have 22 | # created this method. 23 | # 24 | # @param context [Hash, LDContext] 25 | # @return [LDContext] 26 | # 27 | def self.make_context(context) 28 | return context if context.is_a?(LDContext) 29 | 30 | LDContext.create(context) 31 | end 32 | 33 | # 34 | # Returns an error message if the kind is invalid; nil otherwise. 35 | # 36 | # @param kind [any] 37 | # @return [String, nil] 38 | # 39 | def self.validate_kind(kind) 40 | return ERR_KIND_NON_STRING unless kind.is_a?(String) 41 | return ERR_KIND_CANNOT_BE_KIND if kind == "kind" 42 | return ERR_KIND_CANNOT_BE_MULTI if kind == "multi" 43 | ERR_KIND_INVALID_CHARS unless kind.match?(/^[\w.-]+$/) 44 | end 45 | 46 | # 47 | # Returns an error message if the key is invalid; nil otherwise. 48 | # 49 | # @param key [any] 50 | # @return [String, nil] 51 | # 52 | def self.validate_key(key) 53 | return ERR_KEY_NON_STRING unless key.is_a?(String) 54 | ERR_KEY_EMPTY if key == "" 55 | end 56 | 57 | # 58 | # Returns an error message if the name is invalid; nil otherwise. 59 | # 60 | # @param name [any] 61 | # @return [String, nil] 62 | # 63 | def self.validate_name(name) 64 | ERR_NAME_NON_STRING unless name.nil? || name.is_a?(String) 65 | end 66 | 67 | # 68 | # Returns an error message if anonymous is invalid; nil otherwise. 69 | # 70 | # @param anonymous [any] 71 | # @param allow_nil [Boolean] 72 | # @return [String, nil] 73 | # 74 | def self.validate_anonymous(anonymous, allow_nil) 75 | return nil if anonymous.nil? && allow_nil 76 | return nil if [true, false].include? anonymous 77 | 78 | ERR_ANONYMOUS_NON_BOOLEAN 79 | end 80 | 81 | # 82 | # @param kind [String] 83 | # @param key [String] 84 | # @return [String] 85 | # 86 | def self.canonicalize_key_for_kind(kind, key) 87 | # When building a FullyQualifiedKey, ':' and '%' are percent-escaped; 88 | # we do not use a full URL-encoding function because implementations of 89 | # this are inconsistent across platforms. 90 | encoded = key.gsub("%", "%25").gsub(":", "%3A") 91 | 92 | "#{kind}:#{encoded}" 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/data_store.rb: -------------------------------------------------------------------------------- 1 | require 'concurrent' 2 | require "ldclient-rb/interfaces" 3 | 4 | module LaunchDarkly 5 | module Impl 6 | module DataStore 7 | 8 | class DataKind 9 | FEATURES = "features".freeze 10 | SEGMENTS = "segments".freeze 11 | 12 | FEATURE_PREREQ_FN = lambda { |flag| (flag[:prerequisites] || []).map { |p| p[:key] } }.freeze 13 | 14 | attr_reader :namespace 15 | attr_reader :priority 16 | 17 | # 18 | # @param namespace [String] 19 | # @param priority [Integer] 20 | # 21 | def initialize(namespace:, priority:) 22 | @namespace = namespace 23 | @priority = priority 24 | end 25 | 26 | # 27 | # Maintain the same behavior when these data kinds were standard ruby hashes. 28 | # 29 | # @param key [Symbol] 30 | # @return [Object] 31 | # 32 | def [](key) 33 | return priority if key == :priority 34 | return namespace if key == :namespace 35 | return get_dependency_keys_fn() if key == :get_dependency_keys 36 | nil 37 | end 38 | 39 | # 40 | # Retrieve the dependency keys for a particular data kind. Right now, this is only defined for flags. 41 | # 42 | def get_dependency_keys_fn() 43 | return nil unless @namespace == FEATURES 44 | 45 | FEATURE_PREREQ_FN 46 | end 47 | 48 | def eql?(other) 49 | other.is_a?(DataKind) && namespace == other.namespace && priority == other.priority 50 | end 51 | 52 | def hash 53 | [namespace, priority].hash 54 | end 55 | end 56 | 57 | class StatusProvider 58 | include LaunchDarkly::Interfaces::DataStore::StatusProvider 59 | 60 | def initialize(store, update_sink) 61 | # @type [LaunchDarkly::Impl::FeatureStoreClientWrapper] 62 | @store = store 63 | # @type [UpdateSink] 64 | @update_sink = update_sink 65 | end 66 | 67 | def status 68 | @update_sink.last_status.get 69 | end 70 | 71 | def monitoring_enabled? 72 | @store.monitoring_enabled? 73 | end 74 | 75 | def add_listener(listener) 76 | @update_sink.broadcaster.add_listener(listener) 77 | end 78 | 79 | def remove_listener(listener) 80 | @update_sink.broadcaster.remove_listener(listener) 81 | end 82 | end 83 | 84 | class UpdateSink 85 | include LaunchDarkly::Interfaces::DataStore::UpdateSink 86 | 87 | # @return [LaunchDarkly::Impl::Broadcaster] 88 | attr_reader :broadcaster 89 | 90 | # @return [Concurrent::AtomicReference] 91 | attr_reader :last_status 92 | 93 | def initialize(broadcaster) 94 | @broadcaster = broadcaster 95 | @last_status = Concurrent::AtomicReference.new( 96 | LaunchDarkly::Interfaces::DataStore::Status.new(true, false) 97 | ) 98 | end 99 | 100 | def update_status(status) 101 | return if status.nil? 102 | 103 | old_status = @last_status.get_and_set(status) 104 | @broadcaster.broadcast(status) unless old_status == status 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/dependency_tracker.rb: -------------------------------------------------------------------------------- 1 | module LaunchDarkly 2 | module Impl 3 | class DependencyTracker 4 | def initialize 5 | @from = {} 6 | @to = {} 7 | end 8 | 9 | # 10 | # Updates the dependency graph when an item has changed. 11 | # 12 | # @param from_kind [Object] the changed item's kind 13 | # @param from_key [String] the changed item's key 14 | # @param from_item [Object] the changed item 15 | # 16 | def update_dependencies_from(from_kind, from_key, from_item) 17 | from_what = { kind: from_kind, key: from_key } 18 | updated_dependencies = DependencyTracker.compute_dependencies_from(from_kind, from_item) 19 | 20 | old_dependency_set = @from[from_what] 21 | unless old_dependency_set.nil? 22 | old_dependency_set.each do |kind_and_key| 23 | deps_to_this_old_dep = @to[kind_and_key] 24 | deps_to_this_old_dep&.delete(from_what) 25 | end 26 | end 27 | 28 | @from[from_what] = updated_dependencies 29 | updated_dependencies.each do |kind_and_key| 30 | deps_to_this_new_dep = @to[kind_and_key] 31 | if deps_to_this_new_dep.nil? 32 | deps_to_this_new_dep = Set.new 33 | @to[kind_and_key] = deps_to_this_new_dep 34 | end 35 | deps_to_this_new_dep.add(from_what) 36 | end 37 | end 38 | 39 | def self.segment_keys_from_clauses(clauses) 40 | clauses.flat_map do |clause| 41 | if clause.op == :segmentMatch 42 | clause.values.map { |value| {kind: LaunchDarkly::SEGMENTS, key: value }} 43 | else 44 | [] 45 | end 46 | end 47 | end 48 | 49 | # 50 | # @param from_kind [String] 51 | # @param from_item [LaunchDarkly::Impl::Model::FeatureFlag, LaunchDarkly::Impl::Model::Segment] 52 | # @return [Set] 53 | # 54 | def self.compute_dependencies_from(from_kind, from_item) 55 | return Set.new if from_item.nil? 56 | 57 | if from_kind == LaunchDarkly::FEATURES 58 | prereq_keys = from_item.prerequisites.map { |prereq| {kind: from_kind, key: prereq.key} } 59 | segment_keys = from_item.rules.flat_map { |rule| DependencyTracker.segment_keys_from_clauses(rule.clauses) } 60 | 61 | results = Set.new(prereq_keys) 62 | results.merge(segment_keys) 63 | elsif from_kind == LaunchDarkly::SEGMENTS 64 | kind_and_keys = from_item.rules.flat_map do |rule| 65 | DependencyTracker.segment_keys_from_clauses(rule.clauses) 66 | end 67 | Set.new(kind_and_keys) 68 | else 69 | Set.new 70 | end 71 | end 72 | 73 | # 74 | # Clear any tracked dependencies and reset the tracking state to a clean slate. 75 | # 76 | def reset 77 | @from.clear 78 | @to.clear 79 | end 80 | 81 | # 82 | # Populates the given set with the union of the initial item and all items that directly or indirectly 83 | # depend on it (based on the current state of the dependency graph). 84 | # 85 | # @param items_out [Set] 86 | # @param initial_modified_item [Object] 87 | # 88 | def add_affected_items(items_out, initial_modified_item) 89 | return if items_out.include? initial_modified_item 90 | 91 | items_out.add(initial_modified_item) 92 | affected_items = @to[initial_modified_item] 93 | 94 | return if affected_items.nil? 95 | 96 | affected_items.each do |affected_item| 97 | add_affected_items(items_out, affected_item) 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/diagnostic_events.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/util" 2 | 3 | require "rbconfig" 4 | require "securerandom" 5 | 6 | module LaunchDarkly 7 | module Impl 8 | class DiagnosticAccumulator 9 | def self.create_diagnostic_id(sdk_key) 10 | { 11 | diagnosticId: SecureRandom.uuid, 12 | sdkKeySuffix: sdk_key[-6..-1] || sdk_key, 13 | } 14 | end 15 | 16 | def initialize(diagnostic_id) 17 | @id = diagnostic_id 18 | @lock = Mutex.new 19 | self.reset(Util.current_time_millis) 20 | end 21 | 22 | def reset(time) 23 | @data_since_date = time 24 | @stream_inits = [] 25 | end 26 | 27 | def create_init_event(config) 28 | { 29 | kind: 'diagnostic-init', 30 | creationDate: Util.current_time_millis, 31 | id: @id, 32 | configuration: DiagnosticAccumulator.make_config_data(config), 33 | sdk: DiagnosticAccumulator.make_sdk_data(config), 34 | platform: DiagnosticAccumulator.make_platform_data, 35 | } 36 | end 37 | 38 | def record_stream_init(timestamp, failed, duration_millis) 39 | @lock.synchronize do 40 | @stream_inits.push({ timestamp: timestamp, failed: failed, durationMillis: duration_millis }) 41 | end 42 | end 43 | 44 | def create_periodic_event_and_reset(dropped_events, deduplicated_users, events_in_last_batch) 45 | previous_stream_inits = @lock.synchronize do 46 | si = @stream_inits 47 | @stream_inits = [] 48 | si 49 | end 50 | 51 | current_time = Util.current_time_millis 52 | event = { 53 | kind: 'diagnostic', 54 | creationDate: current_time, 55 | id: @id, 56 | dataSinceDate: @data_since_date, 57 | droppedEvents: dropped_events, 58 | deduplicatedUsers: deduplicated_users, 59 | eventsInLastBatch: events_in_last_batch, 60 | streamInits: previous_stream_inits, 61 | } 62 | @data_since_date = current_time 63 | event 64 | end 65 | 66 | def self.make_config_data(config) 67 | ret = { 68 | allAttributesPrivate: config.all_attributes_private, 69 | connectTimeoutMillis: self.seconds_to_millis(config.connect_timeout), 70 | customBaseURI: config.base_uri != Config.default_base_uri, 71 | customEventsURI: config.events_uri != Config.default_events_uri, 72 | customStreamURI: config.stream_uri != Config.default_stream_uri, 73 | diagnosticRecordingIntervalMillis: self.seconds_to_millis(config.diagnostic_recording_interval), 74 | eventsCapacity: config.capacity, 75 | eventsFlushIntervalMillis: self.seconds_to_millis(config.flush_interval), 76 | pollingIntervalMillis: self.seconds_to_millis(config.poll_interval), 77 | socketTimeoutMillis: self.seconds_to_millis(config.read_timeout), 78 | streamingDisabled: !config.stream?, 79 | userKeysCapacity: config.context_keys_capacity, 80 | userKeysFlushIntervalMillis: self.seconds_to_millis(config.context_keys_flush_interval), 81 | usingProxy: ENV.has_key?('http_proxy') || ENV.has_key?('https_proxy') || ENV.has_key?('HTTP_PROXY') || ENV.has_key?('HTTPS_PROXY'), 82 | usingRelayDaemon: config.use_ldd?, 83 | } 84 | ret 85 | end 86 | 87 | def self.make_sdk_data(config) 88 | ret = { 89 | name: 'ruby-server-sdk', 90 | version: LaunchDarkly::VERSION, 91 | } 92 | if config.wrapper_name 93 | ret[:wrapperName] = config.wrapper_name 94 | ret[:wrapperVersion] = config.wrapper_version 95 | end 96 | ret 97 | end 98 | 99 | def self.make_platform_data 100 | conf = RbConfig::CONFIG 101 | { 102 | name: 'ruby', 103 | osArch: conf['host_cpu'], 104 | osName: self.normalize_os_name(conf['host_os']), 105 | osVersion: 'unknown', # there seems to be no portable way to detect this in Ruby 106 | rubyVersion: conf['ruby_version'], 107 | rubyImplementation: Object.constants.include?(:RUBY_ENGINE) ? RUBY_ENGINE : 'unknown', 108 | } 109 | end 110 | 111 | def self.normalize_os_name(name) 112 | case name 113 | when /linux|arch/i 114 | 'Linux' 115 | when /darwin/i 116 | 'MacOS' 117 | when /mswin|windows/i 118 | 'Windows' 119 | else 120 | name 121 | end 122 | end 123 | 124 | def self.seconds_to_millis(s) 125 | (s * 1000).to_i 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/evaluation_with_hook_result.rb: -------------------------------------------------------------------------------- 1 | module LaunchDarkly 2 | module Impl 3 | # 4 | # Simple helper class for returning formatted data. 5 | # 6 | # The variation methods make use of the new hook support. Those methods all need to return an evaluation detail, and 7 | # some other unstructured bit of data. 8 | # 9 | class EvaluationWithHookResult 10 | # 11 | # Return the evaluation detail that was generated as part of the evaluation. 12 | # 13 | # @return [LaunchDarkly::EvaluationDetail] 14 | # 15 | attr_reader :evaluation_detail 16 | 17 | # 18 | # All purpose container for additional return values from the wrapping method 19 | # 20 | # @return [any] 21 | # 22 | attr_reader :results 23 | 24 | # 25 | # @param evaluation_detail [LaunchDarkly::EvaluationDetail] 26 | # @param results [any] 27 | # 28 | def initialize(evaluation_detail, results = nil) 29 | @evaluation_detail = evaluation_detail 30 | @results = results 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/evaluator_bucketing.rb: -------------------------------------------------------------------------------- 1 | module LaunchDarkly 2 | module Impl 3 | # Encapsulates the logic for percentage rollouts. 4 | module EvaluatorBucketing 5 | # Applies either a fixed variation or a rollout for a rule (or the fallthrough rule). 6 | # 7 | # @param flag [Object] the feature flag 8 | # @param vr [LaunchDarkly::Impl::Model::VariationOrRollout] the variation/rollout properties 9 | # @param context [LaunchDarkly::LDContext] the context properties 10 | # @return [Array<[Number, nil], Boolean>] the variation index, or nil if there is an error 11 | # @raise [InvalidReferenceException] 12 | def self.variation_index_for_context(flag, vr, context) 13 | variation = vr.variation 14 | return variation, false unless variation.nil? # fixed variation 15 | rollout = vr.rollout 16 | return nil, false if rollout.nil? 17 | variations = rollout.variations 18 | if !variations.nil? && variations.length > 0 # percentage rollout 19 | rollout_is_experiment = rollout.is_experiment 20 | bucket_by = rollout_is_experiment ? nil : rollout.bucket_by 21 | bucket_by = 'key' if bucket_by.nil? 22 | 23 | seed = rollout.seed 24 | bucket = bucket_context(context, rollout.context_kind, flag.key, bucket_by, flag.salt, seed) # may not be present 25 | in_experiment = rollout_is_experiment && !bucket.nil? 26 | sum = 0 27 | variations.each do |variate| 28 | sum += variate.weight.to_f / 100000.0 29 | if bucket.nil? || bucket < sum 30 | return variate.variation, in_experiment && !variate.untracked 31 | end 32 | end 33 | # The context's bucket value was greater than or equal to the end of the last bucket. This could happen due 34 | # to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag 35 | # data could contain buckets that don't actually add up to 100000. Rather than returning an error in 36 | # this case (or changing the scaling, which would potentially change the results for *all* contexts), we 37 | # will simply put the context in the last bucket. 38 | last_variation = variations[-1] 39 | [last_variation.variation, in_experiment && !last_variation.untracked] 40 | else # the rule isn't well-formed 41 | [nil, false] 42 | end 43 | end 44 | 45 | # Returns a context's bucket value as a floating-point value in `[0, 1)`. 46 | # 47 | # @param context [LDContext] the context properties 48 | # @param context_kind [String, nil] the context kind to match against 49 | # @param key [String] the feature flag key (or segment key, if this is for a segment rule) 50 | # @param bucket_by [String|Symbol] the name of the context attribute to be used for bucketing 51 | # @param salt [String] the feature flag's or segment's salt value 52 | # @return [Float, nil] the bucket value, from 0 inclusive to 1 exclusive 53 | # @raise [InvalidReferenceException] Raised if the clause.attribute is an invalid reference 54 | def self.bucket_context(context, context_kind, key, bucket_by, salt, seed) 55 | matched_context = context.individual_context(context_kind || LaunchDarkly::LDContext::KIND_DEFAULT) 56 | return nil if matched_context.nil? 57 | 58 | reference = (context_kind.nil? || context_kind.empty?) ? Reference.create_literal(bucket_by) : Reference.create(bucket_by) 59 | raise InvalidReferenceException.new(reference.error) unless reference.error.nil? 60 | 61 | context_value = matched_context.get_value_for_reference(reference) 62 | return 0.0 if context_value.nil? 63 | 64 | id_hash = bucketable_string_value(context_value) 65 | return 0.0 if id_hash.nil? 66 | 67 | if seed 68 | hash_key = "%d.%s" % [seed, id_hash] 69 | else 70 | hash_key = "%s.%s.%s" % [key, salt, id_hash] 71 | end 72 | 73 | hash_val = Digest::SHA1.hexdigest(hash_key)[0..14] 74 | hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF) 75 | end 76 | 77 | private 78 | 79 | def self.bucketable_string_value(value) 80 | return value if value.is_a? String 81 | return value.to_s if value.is_a? Integer 82 | nil 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/evaluator_helpers.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/evaluation_detail" 2 | 3 | # This file contains any pieces of low-level evaluation logic that don't need to be inside the Evaluator 4 | # class, because they don't depend on any SDK state outside of their input parameters. 5 | 6 | module LaunchDarkly 7 | module Impl 8 | module EvaluatorHelpers 9 | # 10 | # @param flag [LaunchDarkly::Impl::Model::FeatureFlag] 11 | # @param reason [LaunchDarkly::EvaluationReason] 12 | # 13 | def self.evaluation_detail_for_off_variation(flag, reason) 14 | index = flag.off_variation 15 | index.nil? ? EvaluationDetail.new(nil, nil, reason) : evaluation_detail_for_variation(flag, index, reason) 16 | end 17 | 18 | # 19 | # @param flag [LaunchDarkly::Impl::Model::FeatureFlag] 20 | # @param index [Integer] 21 | # @param reason [LaunchDarkly::EvaluationReason] 22 | # 23 | def self.evaluation_detail_for_variation(flag, index, reason) 24 | vars = flag.variations 25 | if index < 0 || index >= vars.length 26 | EvaluationDetail.new(nil, nil, EvaluationReason::error(EvaluationReason::ERROR_MALFORMED_FLAG)) 27 | # This error condition has already been logged at the time we received the flag data - see model/feature_flag.rb 28 | else 29 | EvaluationDetail.new(vars[index], index, reason) 30 | end 31 | end 32 | 33 | # 34 | # @param context [LaunchDarkly::LDContext] 35 | # @param kind [String, nil] 36 | # @param keys [Enumerable] 37 | # @return [Boolean] 38 | # 39 | def self.context_key_in_target_list(context, kind, keys) 40 | return false unless keys.is_a? Enumerable 41 | return false if keys.empty? 42 | 43 | matched_context = context.individual_context(kind || LaunchDarkly::LDContext::KIND_DEFAULT) 44 | return false if matched_context.nil? 45 | 46 | keys.include? matched_context.key 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/event_sender.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/unbounded_pool" 2 | 3 | require "securerandom" 4 | require "http" 5 | require "stringio" 6 | require "zlib" 7 | 8 | module LaunchDarkly 9 | module Impl 10 | EventSenderResult = Struct.new(:success, :must_shutdown, :time_from_server) 11 | 12 | class EventSender 13 | CURRENT_SCHEMA_VERSION = 4 14 | DEFAULT_RETRY_INTERVAL = 1 15 | 16 | def initialize(sdk_key, config, http_client = nil, retry_interval = DEFAULT_RETRY_INTERVAL) 17 | @sdk_key = sdk_key 18 | @config = config 19 | @events_uri = config.events_uri + "/bulk" 20 | @diagnostic_uri = config.events_uri + "/diagnostic" 21 | @logger = config.logger 22 | @retry_interval = retry_interval 23 | @http_client_pool = UnboundedPool.new( 24 | lambda { LaunchDarkly::Util.new_http_client(@config.events_uri, @config) }, 25 | lambda { |client| client.close }) 26 | end 27 | 28 | def stop 29 | @http_client_pool.dispose_all() 30 | end 31 | 32 | def send_event_data(event_data, description, is_diagnostic) 33 | uri = is_diagnostic ? @diagnostic_uri : @events_uri 34 | payload_id = is_diagnostic ? nil : SecureRandom.uuid 35 | begin 36 | http_client = @http_client_pool.acquire() 37 | response = nil 38 | 2.times do |attempt| 39 | if attempt > 0 40 | @logger.warn { "[LDClient] Will retry posting events after #{@retry_interval} second" } 41 | sleep(@retry_interval) 42 | end 43 | begin 44 | @logger.debug { "[LDClient] sending #{description}: #{event_data}" } 45 | headers = {} 46 | headers["content-type"] = "application/json" 47 | headers["content-encoding"] = "gzip" if @config.compress_events 48 | Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v } 49 | unless is_diagnostic 50 | headers["X-LaunchDarkly-Event-Schema"] = CURRENT_SCHEMA_VERSION.to_s 51 | headers["X-LaunchDarkly-Payload-ID"] = payload_id 52 | end 53 | 54 | body = event_data 55 | if @config.compress_events 56 | gzip = Zlib::GzipWriter.new(StringIO.new) 57 | gzip << event_data 58 | 59 | body = gzip.close.string 60 | end 61 | 62 | response = http_client.request("POST", uri, { 63 | headers: headers, 64 | body: body, 65 | }) 66 | rescue StandardError => exn 67 | @logger.warn { "[LDClient] Error sending events: #{exn.inspect}." } 68 | next 69 | end 70 | status = response.status.code 71 | # must fully read body for persistent connections 72 | body = response.to_s 73 | if status >= 200 && status < 300 74 | res_time = nil 75 | unless response.headers["date"].nil? 76 | begin 77 | res_time = Time.httpdate(response.headers["date"]) 78 | rescue ArgumentError 79 | # Ignored 80 | end 81 | end 82 | return EventSenderResult.new(true, false, res_time) 83 | end 84 | must_shutdown = !LaunchDarkly::Util.http_error_recoverable?(status) 85 | can_retry = !must_shutdown && attempt == 0 86 | message = LaunchDarkly::Util.http_error_message(status, "event delivery", can_retry ? "will retry" : "some events were dropped") 87 | @logger.error { "[LDClient] #{message}" } 88 | if must_shutdown 89 | return EventSenderResult.new(false, true, nil) 90 | end 91 | end 92 | # used up our retries 93 | EventSenderResult.new(false, false, nil) 94 | ensure 95 | @http_client_pool.release(http_client) 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/event_summarizer.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/event_types" 2 | require "set" 3 | 4 | module LaunchDarkly 5 | module Impl 6 | EventSummary = Struct.new(:start_date, :end_date, :counters) 7 | 8 | EventSummaryFlagInfo = Struct.new(:default, :versions, :context_kinds) 9 | 10 | EventSummaryFlagVariationCounter = Struct.new(:value, :count) 11 | 12 | # Manages the state of summarizable information for the EventProcessor, including the 13 | # event counters and context deduplication. Note that the methods of this class are 14 | # deliberately not thread-safe; the EventProcessor is responsible for enforcing 15 | # synchronization across both the summarizer and the event queue. 16 | class EventSummarizer 17 | class Counter 18 | end 19 | 20 | def initialize 21 | clear 22 | end 23 | 24 | # Adds this event to our counters, if it is a type of event we need to count. 25 | def summarize_event(event) 26 | return unless event.is_a?(LaunchDarkly::Impl::EvalEvent) 27 | 28 | counters_for_flag = @counters[event.key] 29 | if counters_for_flag.nil? 30 | counters_for_flag = EventSummaryFlagInfo.new(event.default, Hash.new, Set.new) 31 | @counters[event.key] = counters_for_flag 32 | end 33 | 34 | counters_for_flag_version = counters_for_flag.versions[event.version] 35 | if counters_for_flag_version.nil? 36 | counters_for_flag_version = Hash.new 37 | counters_for_flag.versions[event.version] = counters_for_flag_version 38 | end 39 | 40 | counters_for_flag.context_kinds.merge(event.context.kinds) 41 | 42 | variation_counter = counters_for_flag_version[event.variation] 43 | if variation_counter.nil? 44 | counters_for_flag_version[event.variation] = EventSummaryFlagVariationCounter.new(event.value, 1) 45 | else 46 | variation_counter.count = variation_counter.count + 1 47 | end 48 | 49 | time = event.timestamp 50 | unless time.nil? 51 | @start_date = time if @start_date == 0 || time < @start_date 52 | @end_date = time if time > @end_date 53 | end 54 | end 55 | 56 | # Returns a snapshot of the current summarized event data, and resets this state. 57 | def snapshot 58 | EventSummary.new(@start_date, @end_date, @counters) 59 | end 60 | 61 | def clear 62 | @start_date = 0 63 | @end_date = 0 64 | @counters = {} 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/flag_tracker.rb: -------------------------------------------------------------------------------- 1 | require "concurrent" 2 | require "ldclient-rb/interfaces" 3 | require "forwardable" 4 | 5 | module LaunchDarkly 6 | module Impl 7 | class FlagTracker 8 | include LaunchDarkly::Interfaces::FlagTracker 9 | 10 | extend Forwardable 11 | def_delegators :@broadcaster, :add_listener, :remove_listener 12 | 13 | def initialize(broadcaster, eval_fn) 14 | @broadcaster = broadcaster 15 | @eval_fn = eval_fn 16 | end 17 | 18 | def add_flag_value_change_listener(key, context, listener) 19 | flag_change_listener = FlagValueChangeAdapter.new(key, context, listener, @eval_fn) 20 | add_listener(flag_change_listener) 21 | 22 | flag_change_listener 23 | end 24 | 25 | # 26 | # An adapter which turns a normal flag change listener into a flag value change listener. 27 | # 28 | class FlagValueChangeAdapter 29 | # @param [Symbol] flag_key 30 | # @param [LaunchDarkly::LDContext] context 31 | # @param [#update] listener 32 | # @param [#call] eval_fn 33 | def initialize(flag_key, context, listener, eval_fn) 34 | @flag_key = flag_key 35 | @context = context 36 | @listener = listener 37 | @eval_fn = eval_fn 38 | @value = Concurrent::AtomicReference.new(@eval_fn.call(@flag_key, @context)) 39 | end 40 | 41 | # 42 | # @param [LaunchDarkly::Interfaces::FlagChange] flag_change 43 | # 44 | def update(flag_change) 45 | return unless flag_change.key == @flag_key 46 | 47 | new_eval = @eval_fn.call(@flag_key, @context) 48 | old_eval = @value.get_and_set(new_eval) 49 | 50 | return if new_eval == old_eval 51 | 52 | @listener.update( 53 | LaunchDarkly::Interfaces::FlagValueChange.new(@flag_key, old_eval, new_eval)) 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb: -------------------------------------------------------------------------------- 1 | require 'concurrent/atomics' 2 | require 'ldclient-rb/interfaces' 3 | 4 | module LaunchDarkly 5 | module Impl 6 | module Integrations 7 | module TestData 8 | # @private 9 | class TestDataSource 10 | include LaunchDarkly::Interfaces::DataSource 11 | 12 | def initialize(feature_store, test_data) 13 | @feature_store = feature_store 14 | @test_data = test_data 15 | end 16 | 17 | def initialized? 18 | true 19 | end 20 | 21 | def start 22 | ready = Concurrent::Event.new 23 | ready.set 24 | init_data = @test_data.make_init_data 25 | @feature_store.init(init_data) 26 | ready 27 | end 28 | 29 | def stop 30 | @test_data.closed_instance(self) 31 | end 32 | 33 | def upsert(kind, item) 34 | @feature_store.upsert(kind, item) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/migrations/tracker.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | require "ldclient-rb/impl/sampler" 3 | require "logger" 4 | 5 | module LaunchDarkly 6 | module Impl 7 | module Migrations 8 | class OpTracker 9 | include LaunchDarkly::Interfaces::Migrations::OpTracker 10 | 11 | # 12 | # @param logger [Logger] 13 | # @param key [string] 14 | # @param flag [LaunchDarkly::Impl::Model::FeatureFlag] 15 | # @param context [LaunchDarkly::LDContext] 16 | # @param detail [LaunchDarkly::EvaluationDetail] 17 | # @param default_stage [Symbol] 18 | # 19 | def initialize(logger, key, flag, context, detail, default_stage) 20 | @logger = logger 21 | @key = key 22 | @flag = flag 23 | @context = context 24 | @detail = detail 25 | @default_stage = default_stage 26 | @sampler = LaunchDarkly::Impl::Sampler.new(Random.new) 27 | 28 | @mutex = Mutex.new 29 | 30 | # @type [Symbol, nil] 31 | @operation = nil 32 | 33 | # @type [Set] 34 | @invoked = Set.new 35 | # @type [Boolean, nil] 36 | @consistent = nil 37 | 38 | # @type [Int] 39 | @consistent_ratio = @flag&.migration_settings&.check_ratio 40 | @consistent_ratio = 1 if @consistent_ratio.nil? 41 | 42 | # @type [Set] 43 | @errors = Set.new 44 | # @type [Hash] 45 | @latencies = Hash.new 46 | end 47 | 48 | def operation(operation) 49 | return unless LaunchDarkly::Migrations::VALID_OPERATIONS.include? operation 50 | 51 | @mutex.synchronize do 52 | @operation = operation 53 | end 54 | end 55 | 56 | def invoked(origin) 57 | return unless LaunchDarkly::Migrations::VALID_ORIGINS.include? origin 58 | 59 | @mutex.synchronize do 60 | @invoked.add(origin) 61 | end 62 | end 63 | 64 | def consistent(is_consistent) 65 | @mutex.synchronize do 66 | if @sampler.sample(@consistent_ratio) 67 | begin 68 | @consistent = is_consistent.call 69 | rescue => e 70 | LaunchDarkly::Util.log_exception(@logger, "Exception raised during consistency check; failed to record measurement", e) 71 | end 72 | end 73 | end 74 | end 75 | 76 | def error(origin) 77 | return unless LaunchDarkly::Migrations::VALID_ORIGINS.include? origin 78 | 79 | @mutex.synchronize do 80 | @errors.add(origin) 81 | end 82 | end 83 | 84 | def latency(origin, duration) 85 | return unless LaunchDarkly::Migrations::VALID_ORIGINS.include? origin 86 | return unless duration.is_a? Numeric 87 | return if duration < 0 88 | 89 | @mutex.synchronize do 90 | @latencies[origin] = duration 91 | end 92 | end 93 | 94 | def build 95 | @mutex.synchronize do 96 | return "operation cannot contain an empty key" if @key.empty? 97 | return "operation not provided" if @operation.nil? 98 | return "no origins were invoked" if @invoked.empty? 99 | return "provided context was invalid" unless @context.valid? 100 | 101 | result = check_invoked_consistency 102 | return result unless result == true 103 | 104 | LaunchDarkly::Impl::MigrationOpEvent.new( 105 | LaunchDarkly::Impl::Util.current_time_millis, 106 | @context, 107 | @key, 108 | @flag, 109 | @operation, 110 | @default_stage, 111 | @detail, 112 | @invoked, 113 | @consistent, 114 | @consistent_ratio, 115 | @errors, 116 | @latencies 117 | ) 118 | end 119 | end 120 | 121 | private def check_invoked_consistency 122 | LaunchDarkly::Migrations::VALID_ORIGINS.each do |origin| 123 | next if @invoked.include? origin 124 | 125 | return "provided latency for origin '#{origin}' without recording invocation" if @latencies.include? origin 126 | return "provided error for origin '#{origin}' without recording invocation" if @errors.include? origin 127 | end 128 | 129 | return "provided consistency without recording both invocations" if !@consistent.nil? && @invoked.size != 2 130 | 131 | true 132 | end 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/model/clause.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/reference" 2 | 3 | 4 | # See serialization.rb for implementation notes on the data model classes. 5 | 6 | module LaunchDarkly 7 | module Impl 8 | module Model 9 | class Clause 10 | def initialize(data, errors_out = nil) 11 | @data = data 12 | @context_kind = data[:contextKind] 13 | @op = data[:op].to_sym 14 | if @op == :segmentMatch 15 | @attribute = nil 16 | else 17 | @attribute = (@context_kind.nil? || @context_kind.empty?) ? Reference.create_literal(data[:attribute]) : Reference.create(data[:attribute]) 18 | unless errors_out.nil? || @attribute.error.nil? 19 | errors_out << "clause has invalid attribute: #{@attribute.error}" 20 | end 21 | end 22 | @values = data[:values] || [] 23 | @negate = !!data[:negate] 24 | end 25 | 26 | # @return [Hash] 27 | attr_reader :data 28 | # @return [String|nil] 29 | attr_reader :context_kind 30 | # @return [LaunchDarkly::Reference] 31 | attr_reader :attribute 32 | # @return [Symbol] 33 | attr_reader :op 34 | # @return [Array] 35 | attr_reader :values 36 | # @return [Boolean] 37 | attr_reader :negate 38 | 39 | def as_json 40 | @data 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/model/preprocessed_data.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/evaluator_helpers" 2 | 3 | module LaunchDarkly 4 | module Impl 5 | module Model 6 | # 7 | # Container for a precomputed result that includes a specific variation index and value, an 8 | # evaluation reason, and optionally an alternate evaluation reason that corresponds to the 9 | # "in experiment" state. 10 | # 11 | class EvalResultsForSingleVariation 12 | def initialize(value, variation_index, regular_reason, in_experiment_reason = nil) 13 | @regular_result = EvaluationDetail.new(value, variation_index, regular_reason) 14 | @in_experiment_result = in_experiment_reason ? 15 | EvaluationDetail.new(value, variation_index, in_experiment_reason) : 16 | @regular_result 17 | end 18 | 19 | # @param in_experiment [Boolean] indicates whether we want the result to include 20 | # "inExperiment: true" in the reason or not 21 | # @return [LaunchDarkly::EvaluationDetail] 22 | def get_result(in_experiment = false) 23 | in_experiment ? @in_experiment_result : @regular_result 24 | end 25 | end 26 | 27 | # 28 | # Container for a set of precomputed results, one for each possible flag variation. 29 | # 30 | class EvalResultFactoryMultiVariations 31 | def initialize(variation_factories) 32 | @factories = variation_factories 33 | end 34 | 35 | # @param index [Integer] the variation index 36 | # @param in_experiment [Boolean] indicates whether we want the result to include 37 | # "inExperiment: true" in the reason or not 38 | # @return [LaunchDarkly::EvaluationDetail] 39 | def for_variation(index, in_experiment) 40 | if index < 0 || index >= @factories.length 41 | EvaluationDetail.new(nil, nil, EvaluationReason.error(EvaluationReason::ERROR_MALFORMED_FLAG)) 42 | else 43 | @factories[index].get_result(in_experiment) 44 | end 45 | end 46 | end 47 | 48 | class Preprocessor 49 | # @param flag [LaunchDarkly::Impl::Model::FeatureFlag] 50 | # @param regular_reason [LaunchDarkly::EvaluationReason] 51 | # @param in_experiment_reason [LaunchDarkly::EvaluationReason] 52 | # @return [EvalResultFactoryMultiVariations] 53 | def self.precompute_multi_variation_results(flag, regular_reason, in_experiment_reason) 54 | factories = [] 55 | vars = flag[:variations] || [] 56 | vars.each_index do |index| 57 | factories << EvalResultsForSingleVariation.new(vars[index], index, regular_reason, in_experiment_reason) 58 | end 59 | EvalResultFactoryMultiVariations.new(factories) 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/model/segment.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/model/clause" 2 | require "ldclient-rb/impl/model/preprocessed_data" 3 | require "set" 4 | 5 | # See serialization.rb for implementation notes on the data model classes. 6 | 7 | module LaunchDarkly 8 | module Impl 9 | module Model 10 | class Segment 11 | # @param data [Hash] 12 | # @param logger [Logger|nil] 13 | def initialize(data, logger = nil) 14 | raise ArgumentError, "expected hash but got #{data.class}" unless data.is_a?(Hash) 15 | errors = [] 16 | @data = data 17 | @key = data[:key] 18 | @version = data[:version] 19 | @deleted = !!data[:deleted] 20 | return if @deleted 21 | @included = data[:included] || [] 22 | @excluded = data[:excluded] || [] 23 | @included_contexts = (data[:includedContexts] || []).map do |target_data| 24 | SegmentTarget.new(target_data) 25 | end 26 | @excluded_contexts = (data[:excludedContexts] || []).map do |target_data| 27 | SegmentTarget.new(target_data) 28 | end 29 | @rules = (data[:rules] || []).map do |rule_data| 30 | SegmentRule.new(rule_data, errors) 31 | end 32 | @unbounded = !!data[:unbounded] 33 | @unbounded_context_kind = data[:unboundedContextKind] || LDContext::KIND_DEFAULT 34 | @generation = data[:generation] 35 | @salt = data[:salt] 36 | unless logger.nil? 37 | errors.each do |message| 38 | logger.error("[LDClient] Data inconsistency in segment \"#{@key}\": #{message}") 39 | end 40 | end 41 | end 42 | 43 | # @return [Hash] 44 | attr_reader :data 45 | # @return [String] 46 | attr_reader :key 47 | # @return [Integer] 48 | attr_reader :version 49 | # @return [Boolean] 50 | attr_reader :deleted 51 | # @return [Array] 52 | attr_reader :included 53 | # @return [Array] 54 | attr_reader :excluded 55 | # @return [Array] 56 | attr_reader :included_contexts 57 | # @return [Array] 58 | attr_reader :excluded_contexts 59 | # @return [Array] 60 | attr_reader :rules 61 | # @return [Boolean] 62 | attr_reader :unbounded 63 | # @return [String] 64 | attr_reader :unbounded_context_kind 65 | # @return [Integer|nil] 66 | attr_reader :generation 67 | # @return [String] 68 | attr_reader :salt 69 | 70 | # This method allows us to read properties of the object as if it's just a hash. Currently this is 71 | # necessary because some data store logic is still written to expect hashes; we can remove it once 72 | # we migrate entirely to using attributes of the class. 73 | def [](key) 74 | @data[key] 75 | end 76 | 77 | def ==(other) 78 | other.is_a?(Segment) && other.data == self.data 79 | end 80 | 81 | def as_json(*) # parameter is unused, but may be passed if we're using the json gem 82 | @data 83 | end 84 | 85 | # Same as as_json, but converts the JSON structure into a string. 86 | def to_json(*a) 87 | as_json.to_json(*a) 88 | end 89 | end 90 | 91 | class SegmentTarget 92 | def initialize(data) 93 | @data = data 94 | @context_kind = data[:contextKind] 95 | @values = Set.new(data[:values] || []) 96 | end 97 | 98 | # @return [Hash] 99 | attr_reader :data 100 | # @return [String] 101 | attr_reader :context_kind 102 | # @return [Set] 103 | attr_reader :values 104 | end 105 | 106 | class SegmentRule 107 | def initialize(data, errors_out = nil) 108 | @data = data 109 | @clauses = (data[:clauses] || []).map do |clause_data| 110 | Clause.new(clause_data, errors_out) 111 | end 112 | @weight = data[:weight] 113 | @bucket_by = data[:bucketBy] 114 | @rollout_context_kind = data[:rolloutContextKind] 115 | end 116 | 117 | # @return [Hash] 118 | attr_reader :data 119 | # @return [Array] 120 | attr_reader :clauses 121 | # @return [Integer|nil] 122 | attr_reader :weight 123 | # @return [String|nil] 124 | attr_reader :bucket_by 125 | # @return [String|nil] 126 | attr_reader :rollout_context_kind 127 | end 128 | 129 | # Clause is defined in its own file because clauses are used by both flags and segments 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/model/serialization.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/model/feature_flag" 2 | require "ldclient-rb/impl/model/preprocessed_data" 3 | require "ldclient-rb/impl/model/segment" 4 | 5 | # General implementation notes about the data model classes in LaunchDarkly::Impl::Model-- 6 | # 7 | # As soon as we receive flag/segment JSON data from LaunchDarkly (or, read it from a database), we 8 | # transform it into the model classes FeatureFlag, Segment, etc. The constructor of each of these 9 | # classes takes a hash (the parsed JSON), and transforms it into an internal representation that 10 | # is more efficient for evaluations. 11 | # 12 | # Validation works as follows: 13 | # - A property value that is of the correct type, but is invalid for other reasons (for example, 14 | # if a flag rule refers to variation index 5, but there are only 2 variations in the flag), does 15 | # not prevent the flag from being parsed and stored. It does cause a warning to be logged, if a 16 | # logger was passed to the constructor. 17 | # - If a value is completely invalid for the schema, the constructor may throw an 18 | # exception, causing the whole data set to be rejected. This is consistent with the behavior of 19 | # the strongly-typed SDKs. 20 | # 21 | # Currently, the model classes also retain the original hash of the parsed JSON. This is because 22 | # we may need to re-serialize them to JSON, and building the JSON on the fly would be very 23 | # inefficient, so each model class has a to_json method that just returns the same Hash. If we 24 | # are able in the future to either use a custom streaming serializer, or pass the JSON data 25 | # straight through from LaunchDarkly to a database instead of re-serializing, we could stop 26 | # retaining this data. 27 | 28 | module LaunchDarkly 29 | module Impl 30 | module Model 31 | # Abstraction of deserializing a feature flag or segment that was read from a data store or 32 | # received from LaunchDarkly. 33 | # 34 | # SDK code outside of Impl::Model should use this method instead of calling the model class 35 | # constructors directly, so as not to rely on implementation details. 36 | # 37 | # @param kind [Hash] normally either FEATURES or SEGMENTS 38 | # @param input [object] a JSON string or a parsed hash (or a data model object, in which case 39 | # we'll just return the original object) 40 | # @param logger [Logger|nil] logs errors if there are any data validation problems 41 | # @return [Object] the flag or segment (or, for an unknown data kind, the data as a hash) 42 | def self.deserialize(kind, input, logger = nil) 43 | return nil if input.nil? 44 | return input if !input.is_a?(String) && !input.is_a?(Hash) 45 | data = input.is_a?(Hash) ? input : JSON.parse(input, symbolize_names: true) 46 | case kind 47 | when FEATURES 48 | FeatureFlag.new(data, logger) 49 | when SEGMENTS 50 | Segment.new(data, logger) 51 | else 52 | data 53 | end 54 | end 55 | 56 | # Abstraction of serializing a feature flag or segment that will be written to a data store. 57 | # Currently we just call to_json, but SDK code outside of Impl::Model should use this method 58 | # instead of to_json, so as not to rely on implementation details. 59 | def self.serialize(kind, item) 60 | item.to_json 61 | end 62 | 63 | # Translates a { flags: ..., segments: ... } object received from LaunchDarkly to the data store format. 64 | def self.make_all_store_data(received_data, logger = nil) 65 | { 66 | FEATURES => (received_data[:flags] || {}).transform_values { |data| FeatureFlag.new(data, logger) }, 67 | SEGMENTS => (received_data[:segments] || {}).transform_values { |data| Segment.new(data, logger) }, 68 | } 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/repeating_task.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/util" 2 | 3 | require "concurrent/atomics" 4 | 5 | module LaunchDarkly 6 | module Impl 7 | class RepeatingTask 8 | attr_reader :name 9 | 10 | def initialize(interval, start_delay, task, logger, name) 11 | @interval = interval 12 | @start_delay = start_delay 13 | @task = task 14 | @logger = logger 15 | @stopped = Concurrent::AtomicBoolean.new(false) 16 | @worker = nil 17 | @name = name 18 | end 19 | 20 | def start 21 | @worker = Thread.new do 22 | sleep(@start_delay) unless @start_delay.nil? || @start_delay == 0 23 | 24 | until @stopped.value do 25 | started_at = Time.now 26 | begin 27 | @task.call 28 | rescue => e 29 | LaunchDarkly::Util.log_exception(@logger, "Uncaught exception from repeating task", e) 30 | end 31 | delta = @interval - (Time.now - started_at) 32 | if delta > 0 33 | sleep(delta) 34 | end 35 | end 36 | end 37 | 38 | @worker.name = @name 39 | end 40 | 41 | def stop 42 | if @stopped.make_true 43 | if @worker && @worker.alive? && @worker != Thread.current 44 | @worker.run # causes the thread to wake up if it's currently in a sleep 45 | @worker.join 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/sampler.rb: -------------------------------------------------------------------------------- 1 | module LaunchDarkly 2 | module Impl 3 | class Sampler 4 | # 5 | # @param random [Random] 6 | # 7 | def initialize(random) 8 | @random = random 9 | end 10 | 11 | # 12 | # @param ratio [Int] 13 | # 14 | # @return [Boolean] 15 | # 16 | def sample(ratio) 17 | return false unless ratio.is_a? Integer 18 | return false if ratio <= 0 19 | return true if ratio == 1 20 | 21 | @random.rand(1.0) < 1.0 / ratio 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/store_client_wrapper.rb: -------------------------------------------------------------------------------- 1 | require "concurrent" 2 | require "ldclient-rb/interfaces" 3 | require "ldclient-rb/impl/store_data_set_sorter" 4 | 5 | module LaunchDarkly 6 | module Impl 7 | # 8 | # Provides additional behavior that the client requires before or after feature store operations. 9 | # This just means sorting the data set for init() and dealing with data store status listeners. 10 | # 11 | class FeatureStoreClientWrapper 12 | include Interfaces::FeatureStore 13 | 14 | def initialize(store, store_update_sink, logger) 15 | # @type [LaunchDarkly::Interfaces::FeatureStore] 16 | @store = store 17 | 18 | @monitoring_enabled = does_store_support_monitoring? 19 | 20 | # @type [LaunchDarkly::Impl::DataStore::UpdateSink] 21 | @store_update_sink = store_update_sink 22 | @logger = logger 23 | 24 | @mutex = Mutex.new # Covers the following variables 25 | @last_available = true 26 | # @type [LaunchDarkly::Impl::RepeatingTask, nil] 27 | @poller = nil 28 | end 29 | 30 | def init(all_data) 31 | wrapper { @store.init(FeatureStoreDataSetSorter.sort_all_collections(all_data)) } 32 | end 33 | 34 | def get(kind, key) 35 | wrapper { @store.get(kind, key) } 36 | end 37 | 38 | def all(kind) 39 | wrapper { @store.all(kind) } 40 | end 41 | 42 | def upsert(kind, item) 43 | wrapper { @store.upsert(kind, item) } 44 | end 45 | 46 | def delete(kind, key, version) 47 | wrapper { @store.delete(kind, key, version) } 48 | end 49 | 50 | def initialized? 51 | @store.initialized? 52 | end 53 | 54 | def stop 55 | @store.stop 56 | @mutex.synchronize do 57 | return if @poller.nil? 58 | 59 | @poller.stop 60 | @poller = nil 61 | end 62 | end 63 | 64 | def monitoring_enabled? 65 | @monitoring_enabled 66 | end 67 | 68 | private def wrapper() 69 | begin 70 | yield 71 | rescue => e 72 | update_availability(false) if @monitoring_enabled 73 | raise 74 | end 75 | end 76 | 77 | private def update_availability(available) 78 | @mutex.synchronize do 79 | return if available == @last_available 80 | @last_available = available 81 | end 82 | 83 | status = LaunchDarkly::Interfaces::DataStore::Status.new(available, false) 84 | 85 | @logger.warn("Persistent store is available again") if available 86 | 87 | @store_update_sink.update_status(status) 88 | 89 | if available 90 | @mutex.synchronize do 91 | return if @poller.nil? 92 | 93 | @poller.stop 94 | @poller = nil 95 | end 96 | 97 | return 98 | end 99 | 100 | @logger.warn("Detected persistent store unavailability; updates will be cached until it recovers.") 101 | 102 | task = Impl::RepeatingTask.new(0.5, 0, -> { self.check_availability }, @logger, 'LD/StoreWrapper#check_availability') 103 | 104 | @mutex.synchronize do 105 | @poller = task 106 | @poller.start 107 | end 108 | end 109 | 110 | private def check_availability 111 | begin 112 | update_availability(true) if @store.available? 113 | rescue => e 114 | @logger.error("Unexpected error from data store status function: #{e}") 115 | end 116 | end 117 | 118 | # This methods determines whether the wrapped store can support enabling monitoring. 119 | # 120 | # The wrapped store must provide a monitoring_enabled method, which must 121 | # be true. But this alone is not sufficient. 122 | # 123 | # Because this class wraps all interactions with a provided store, it can 124 | # technically "monitor" any store. However, monitoring also requires that 125 | # we notify listeners when the store is available again. 126 | # 127 | # We determine this by checking the store's `available?` method, so this 128 | # is also a requirement for monitoring support. 129 | # 130 | # These extra checks won't be necessary once `available` becomes a part 131 | # of the core interface requirements and this class no longer wraps every 132 | # feature store. 133 | private def does_store_support_monitoring? 134 | return false unless @store.respond_to? :monitoring_enabled? 135 | return false unless @store.respond_to? :available? 136 | 137 | @store.monitoring_enabled? 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/store_data_set_sorter.rb: -------------------------------------------------------------------------------- 1 | 2 | module LaunchDarkly 3 | module Impl 4 | # 5 | # Implements a dependency graph ordering for data to be stored in a feature store. We must use this 6 | # on every data set that will be passed to the feature store's init() method. 7 | # 8 | class FeatureStoreDataSetSorter 9 | # 10 | # Returns a copy of the input hash that has the following guarantees: the iteration order of the outer 11 | # hash will be in ascending order by the VersionDataKind's :priority property (if any), and for each 12 | # data kind that has a :get_dependency_keys function, the inner hash will have an iteration order 13 | # where B is before A if A has a dependency on B. 14 | # 15 | # This implementation relies on the fact that hashes in Ruby have an iteration order that is the same 16 | # as the insertion order. Also, due to the way we deserialize JSON received from LaunchDarkly, the 17 | # keys in the inner hash will always be symbols. 18 | # 19 | def self.sort_all_collections(all_data) 20 | outer_hash = {} 21 | kinds = all_data.keys.sort_by { |k| 22 | k[:priority].nil? ? k[:namespace].length : k[:priority] # arbitrary order if priority is unknown 23 | } 24 | kinds.each do |kind| 25 | items = all_data[kind] 26 | outer_hash[kind] = self.sort_collection(kind, items) 27 | end 28 | outer_hash 29 | end 30 | 31 | def self.sort_collection(kind, input) 32 | dependency_fn = kind[:get_dependency_keys] 33 | return input if dependency_fn.nil? || input.empty? 34 | remaining_items = input.clone 35 | items_out = {} 36 | until remaining_items.empty? 37 | # pick a random item that hasn't been updated yet 38 | key, item = remaining_items.first 39 | self.add_with_dependencies_first(item, dependency_fn, remaining_items, items_out) 40 | end 41 | items_out 42 | end 43 | 44 | def self.add_with_dependencies_first(item, dependency_fn, remaining_items, items_out) 45 | item_key = item[:key].to_sym 46 | remaining_items.delete(item_key) # we won't need to visit this item again 47 | dependency_fn.call(item).each do |dep_key| 48 | dep_item = remaining_items[dep_key.to_sym] 49 | self.add_with_dependencies_first(dep_item, dependency_fn, remaining_items, items_out) unless dep_item.nil? 50 | end 51 | items_out[item_key] = item 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/unbounded_pool.rb: -------------------------------------------------------------------------------- 1 | module LaunchDarkly 2 | module Impl 3 | # A simple thread safe generic unbounded resource pool abstraction 4 | class UnboundedPool 5 | def initialize(instance_creator, instance_destructor) 6 | @pool = Array.new 7 | @lock = Mutex.new 8 | @instance_creator = instance_creator 9 | @instance_destructor = instance_destructor 10 | end 11 | 12 | def acquire 13 | @lock.synchronize { 14 | if @pool.length == 0 15 | @instance_creator.call() 16 | else 17 | @pool.pop() 18 | end 19 | } 20 | end 21 | 22 | def release(instance) 23 | @lock.synchronize { @pool.push(instance) } 24 | end 25 | 26 | def dispose_all 27 | @lock.synchronize { 28 | @pool.map { |instance| @instance_destructor.call(instance) } unless @instance_destructor.nil? 29 | @pool.clear() 30 | } 31 | end 32 | end 33 | end 34 | end -------------------------------------------------------------------------------- /lib/ldclient-rb/impl/util.rb: -------------------------------------------------------------------------------- 1 | module LaunchDarkly 2 | module Impl 3 | module Util 4 | def self.bool?(aObject) 5 | [true,false].include? aObject 6 | end 7 | 8 | def self.current_time_millis 9 | (Time.now.to_f * 1000).to_i 10 | end 11 | 12 | def self.default_http_headers(sdk_key, config) 13 | ret = { "Authorization" => sdk_key, "User-Agent" => "RubyClient/" + LaunchDarkly::VERSION } 14 | 15 | ret["X-LaunchDarkly-Instance-Id"] = config.instance_id unless config.instance_id.nil? 16 | 17 | if config.wrapper_name 18 | ret["X-LaunchDarkly-Wrapper"] = config.wrapper_name + 19 | (config.wrapper_version ? "/" + config.wrapper_version : "") 20 | end 21 | 22 | app_value = application_header_value config.application 23 | ret["X-LaunchDarkly-Tags"] = app_value unless app_value.nil? || app_value.empty? 24 | 25 | ret 26 | end 27 | 28 | # 29 | # Generate an HTTP Header value containing the application meta information (@see #application). 30 | # 31 | # @return [String] 32 | # 33 | def self.application_header_value(application) 34 | parts = [] 35 | unless application[:id].empty? 36 | parts << "application-id/#{application[:id]}" 37 | end 38 | 39 | unless application[:version].empty? 40 | parts << "application-version/#{application[:version]}" 41 | end 42 | 43 | parts.join(" ") 44 | end 45 | 46 | # 47 | # @param value [String] 48 | # @param name [Symbol] 49 | # @param logger [Logger] 50 | # @return [String] 51 | # 52 | def self.validate_application_value(value, name, logger) 53 | value = value.to_s 54 | 55 | return "" if value.empty? 56 | 57 | if value.length > 64 58 | logger.warn { "Value of application[#{name}] was longer than 64 characters and was discarded" } 59 | return "" 60 | end 61 | 62 | if /[^a-zA-Z0-9._-]/.match?(value) 63 | logger.warn { "Value of application[#{name}] contained invalid characters and was discarded" } 64 | return "" 65 | end 66 | 67 | value 68 | end 69 | 70 | # 71 | # @param app [Hash] 72 | # @param logger [Logger] 73 | # @return [Hash] 74 | # 75 | def self.validate_application_info(app, logger) 76 | { 77 | id: validate_application_value(app[:id], :id, logger), 78 | version: validate_application_value(app[:version], :version, logger), 79 | } 80 | end 81 | 82 | # 83 | # @param value [String, nil] 84 | # @param logger [Logger] 85 | # @return [String, nil] 86 | # 87 | def self.validate_payload_filter_key(value, logger) 88 | return nil if value.nil? 89 | return value if value.is_a?(String) && /^[a-zA-Z0-9][._\-a-zA-Z0-9]*$/.match?(value) 90 | 91 | logger.warn { 92 | "Invalid payload filter configured, full environment will be fetched. Ensure the filter key is not empty and was copied correctly from LaunchDarkly settings." 93 | } 94 | nil 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/ldclient-rb/in_memory_store.rb: -------------------------------------------------------------------------------- 1 | require "concurrent/atomics" 2 | 3 | module LaunchDarkly 4 | 5 | # These constants denote the types of data that can be stored in the feature store. If 6 | # we add another storable data type in the future, as long as it follows the same pattern 7 | # (having "key", "version", and "deleted" properties), we only need to add a corresponding 8 | # constant here and the existing store should be able to handle it. 9 | # 10 | # The :priority and :get_dependency_keys properties are used by FeatureStoreDataSetSorter 11 | # to ensure data consistency during non-atomic updates. 12 | 13 | # @private 14 | FEATURES = Impl::DataStore::DataKind.new(namespace: "features", priority: 1).freeze 15 | 16 | # @private 17 | SEGMENTS = Impl::DataStore::DataKind.new(namespace: "segments", priority: 0).freeze 18 | 19 | # @private 20 | ALL_KINDS = [FEATURES, SEGMENTS].freeze 21 | 22 | # 23 | # Default implementation of the LaunchDarkly client's feature store, using an in-memory 24 | # cache. This object holds feature flags and related data received from LaunchDarkly. 25 | # Database-backed implementations are available in {LaunchDarkly::Integrations}. 26 | # 27 | class InMemoryFeatureStore 28 | include LaunchDarkly::Interfaces::FeatureStore 29 | 30 | def initialize 31 | @items = Hash.new 32 | @lock = Concurrent::ReadWriteLock.new 33 | @initialized = Concurrent::AtomicBoolean.new(false) 34 | end 35 | 36 | def monitoring_enabled? 37 | false 38 | end 39 | 40 | def get(kind, key) 41 | @lock.with_read_lock do 42 | coll = @items[kind] 43 | f = coll.nil? ? nil : coll[key.to_sym] 44 | (f.nil? || f[:deleted]) ? nil : f 45 | end 46 | end 47 | 48 | def all(kind) 49 | @lock.with_read_lock do 50 | coll = @items[kind] 51 | (coll.nil? ? Hash.new : coll).select { |_k, f| not f[:deleted] } 52 | end 53 | end 54 | 55 | def delete(kind, key, version) 56 | @lock.with_write_lock do 57 | coll = @items[kind] 58 | if coll.nil? 59 | coll = Hash.new 60 | @items[kind] = coll 61 | end 62 | old = coll[key.to_sym] 63 | 64 | if old.nil? || old[:version] < version 65 | coll[key.to_sym] = { deleted: true, version: version } 66 | end 67 | end 68 | end 69 | 70 | def init(all_data) 71 | @lock.with_write_lock do 72 | @items.replace(all_data) 73 | @initialized.make_true 74 | end 75 | end 76 | 77 | def upsert(kind, item) 78 | @lock.with_write_lock do 79 | coll = @items[kind] 80 | if coll.nil? 81 | coll = Hash.new 82 | @items[kind] = coll 83 | end 84 | old = coll[item[:key].to_sym] 85 | 86 | if old.nil? || old[:version] < item[:version] 87 | coll[item[:key].to_sym] = item 88 | end 89 | end 90 | end 91 | 92 | def initialized? 93 | @initialized.value 94 | end 95 | 96 | def stop 97 | # nothing to do 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/ldclient-rb/integrations.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/integrations/consul" 2 | require "ldclient-rb/integrations/dynamodb" 3 | require "ldclient-rb/integrations/file_data" 4 | require "ldclient-rb/integrations/redis" 5 | require "ldclient-rb/integrations/test_data" 6 | require "ldclient-rb/integrations/util/store_wrapper" 7 | -------------------------------------------------------------------------------- /lib/ldclient-rb/integrations/consul.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/integrations/consul_impl" 2 | require "ldclient-rb/integrations/util/store_wrapper" 3 | 4 | module LaunchDarkly 5 | module Integrations 6 | # 7 | # Integration with [Consul](https://www.consul.io/). 8 | # 9 | # Note that in order to use this integration, you must first install the gem `diplomat`. 10 | # 11 | # @since 5.5.0 12 | # 13 | module Consul 14 | # 15 | # Default value for the `prefix` option for {new_feature_store}. 16 | # 17 | # @return [String] the default key prefix 18 | # 19 | def self.default_prefix 20 | 'launchdarkly' 21 | end 22 | 23 | # 24 | # Creates a Consul-backed persistent feature store. 25 | # 26 | # To use this method, you must first install the gem `diplomat`. Then, put the object returned by 27 | # this method into the `feature_store` property of your client configuration ({LaunchDarkly::Config}). 28 | # 29 | # @param opts [Hash] the configuration options 30 | # @option opts [Hash] :consul_config an instance of `Diplomat::Configuration` to replace the default 31 | # Consul client configuration (note that this is exactly the same as modifying `Diplomat.configuration`) 32 | # @option opts [String] :url shortcut for setting the `url` property of the Consul client configuration 33 | # @option opts [String] :prefix namespace prefix to add to all keys used by LaunchDarkly 34 | # @option opts [Logger] :logger a `Logger` instance; defaults to `Config.default_logger` 35 | # @option opts [Integer] :expiration (15) expiration time for the in-memory cache, in seconds; 0 for no local caching 36 | # @option opts [Integer] :capacity (1000) maximum number of items in the cache 37 | # @return [LaunchDarkly::Interfaces::FeatureStore] a feature store object 38 | # 39 | def self.new_feature_store(opts = {}) 40 | core = LaunchDarkly::Impl::Integrations::Consul::ConsulFeatureStoreCore.new(opts) 41 | LaunchDarkly::Integrations::Util::CachingStoreWrapper.new(core, opts) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ldclient-rb/memoized_value.rb: -------------------------------------------------------------------------------- 1 | 2 | module LaunchDarkly 3 | # Simple implementation of a thread-safe memoized value whose generator function will never be 4 | # run more than once, and whose value can be overridden by explicit assignment. 5 | # Note that we no longer use this class and it will be removed in a future version. 6 | # @private 7 | class MemoizedValue 8 | def initialize(&generator) 9 | @generator = generator 10 | @mutex = Mutex.new 11 | @inited = false 12 | @value = nil 13 | end 14 | 15 | def get 16 | @mutex.synchronize do 17 | unless @inited 18 | @value = @generator.call 19 | @inited = true 20 | end 21 | end 22 | @value 23 | end 24 | 25 | def set(value) 26 | @mutex.synchronize do 27 | @value = value 28 | @inited = true 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/ldclient-rb/non_blocking_thread_pool.rb: -------------------------------------------------------------------------------- 1 | require "concurrent" 2 | require "concurrent/atomics" 3 | require "concurrent/executors" 4 | require "thread" 5 | 6 | module LaunchDarkly 7 | # Simple wrapper for a FixedThreadPool that rejects new jobs if all the threads are busy, rather 8 | # than blocking. Also provides a way to wait for all jobs to finish without shutting down. 9 | # @private 10 | class NonBlockingThreadPool 11 | def initialize(capacity, name = 'LD/NonBlockingThreadPool') 12 | @capacity = capacity 13 | @pool = Concurrent::FixedThreadPool.new(capacity, name: name) 14 | @semaphore = Concurrent::Semaphore.new(capacity) 15 | end 16 | 17 | # Attempts to submit a job, but only if a worker is available. Unlike the regular post method, 18 | # this returns a value: true if the job was submitted, false if all workers are busy. 19 | def post 20 | unless @semaphore.try_acquire(1) 21 | return 22 | end 23 | @pool.post do 24 | begin 25 | yield 26 | ensure 27 | @semaphore.release(1) 28 | end 29 | end 30 | end 31 | 32 | # Waits until no jobs are executing, without shutting down the pool. 33 | def wait_all 34 | @semaphore.acquire(@capacity) 35 | @semaphore.release(@capacity) 36 | end 37 | 38 | def shutdown 39 | @pool.shutdown 40 | end 41 | 42 | def wait_for_termination 43 | @pool.wait_for_termination 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/ldclient-rb/polling.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/repeating_task" 2 | 3 | require "concurrent/atomics" 4 | require "json" 5 | require "thread" 6 | 7 | module LaunchDarkly 8 | # @private 9 | class PollingProcessor 10 | def initialize(config, requestor) 11 | @config = config 12 | @requestor = requestor 13 | @initialized = Concurrent::AtomicBoolean.new(false) 14 | @started = Concurrent::AtomicBoolean.new(false) 15 | @ready = Concurrent::Event.new 16 | @task = Impl::RepeatingTask.new(@config.poll_interval, 0, -> { self.poll }, @config.logger, 'LD/PollingDataSource') 17 | end 18 | 19 | def initialized? 20 | @initialized.value 21 | end 22 | 23 | def start 24 | return @ready unless @started.make_true 25 | @config.logger.info { "[LDClient] Initializing polling connection" } 26 | @task.start 27 | @ready 28 | end 29 | 30 | def stop 31 | stop_with_error_info 32 | end 33 | 34 | def poll 35 | begin 36 | all_data = @requestor.request_all_data 37 | if all_data 38 | update_sink_or_data_store.init(all_data) 39 | if @initialized.make_true 40 | @config.logger.info { "[LDClient] Polling connection initialized" } 41 | @ready.set 42 | end 43 | end 44 | @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) 45 | rescue JSON::ParserError => e 46 | @config.logger.error { "[LDClient] JSON parsing failed for polling response." } 47 | error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( 48 | LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA, 49 | 0, 50 | e.to_s, 51 | Time.now 52 | ) 53 | @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info) 54 | rescue UnexpectedResponseError => e 55 | error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( 56 | LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE, e.status, nil, Time.now) 57 | message = Util.http_error_message(e.status, "polling request", "will retry") 58 | @config.logger.error { "[LDClient] #{message}" } 59 | 60 | if Util.http_error_recoverable?(e.status) 61 | @config.data_source_update_sink&.update_status( 62 | LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, 63 | error_info 64 | ) 65 | else 66 | @ready.set # if client was waiting on us, make it stop waiting - has no effect if already set 67 | stop_with_error_info error_info 68 | end 69 | rescue StandardError => e 70 | Util.log_exception(@config.logger, "Exception while polling", e) 71 | @config.data_source_update_sink&.update_status( 72 | LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, 73 | LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN, 0, e.to_s, Time.now) 74 | ) 75 | end 76 | end 77 | 78 | # 79 | # The original implementation of this class relied on the feature store 80 | # directly, which we are trying to move away from. Customers who might have 81 | # instantiated this directly for some reason wouldn't know they have to set 82 | # the config's sink manually, so we have to fall back to the store if the 83 | # sink isn't present. 84 | # 85 | # The next major release should be able to simplify this structure and 86 | # remove the need for fall back to the data store because the update sink 87 | # should always be present. 88 | # 89 | private def update_sink_or_data_store 90 | @config.data_source_update_sink || @config.feature_store 91 | end 92 | 93 | # 94 | # @param [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] error_info 95 | # 96 | private def stop_with_error_info(error_info = nil) 97 | @task.stop 98 | @config.logger.info { "[LDClient] Polling connection stopped" } 99 | @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error_info) 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/ldclient-rb/requestor.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/model/serialization" 2 | 3 | require "concurrent/atomics" 4 | require "json" 5 | require "uri" 6 | require "http" 7 | 8 | module LaunchDarkly 9 | # @private 10 | class UnexpectedResponseError < StandardError 11 | def initialize(status) 12 | @status = status 13 | super("HTTP error #{status}") 14 | end 15 | 16 | def status 17 | @status 18 | end 19 | end 20 | 21 | # @private 22 | class Requestor 23 | CacheEntry = Struct.new(:etag, :body) 24 | 25 | def initialize(sdk_key, config) 26 | @sdk_key = sdk_key 27 | @config = config 28 | @http_client = LaunchDarkly::Util.new_http_client(config.base_uri, config) 29 | .use(:auto_inflate) 30 | .headers("Accept-Encoding" => "gzip") 31 | @cache = @config.cache_store 32 | end 33 | 34 | def request_all_data() 35 | all_data = JSON.parse(make_request("/sdk/latest-all"), symbolize_names: true) 36 | Impl::Model.make_all_store_data(all_data, @config.logger) 37 | end 38 | 39 | def stop 40 | begin 41 | @http_client.close 42 | rescue 43 | end 44 | end 45 | 46 | private 47 | 48 | def make_request(path) 49 | uri = URI( 50 | Util.add_payload_filter_key(@config.base_uri + path, @config) 51 | ) 52 | headers = {} 53 | Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v } 54 | headers["Connection"] = "keep-alive" 55 | cached = @cache.read(uri) 56 | unless cached.nil? 57 | headers["If-None-Match"] = cached.etag 58 | end 59 | response = @http_client.request("GET", uri, { 60 | headers: headers, 61 | }) 62 | status = response.status.code 63 | # must fully read body for persistent connections 64 | body = response.to_s 65 | @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{response.headers.to_h}\n\tbody: #{body}" } 66 | if status == 304 && !cached.nil? 67 | body = cached.body 68 | else 69 | @cache.delete(uri) 70 | if status < 200 || status >= 300 71 | raise UnexpectedResponseError.new(status) 72 | end 73 | body = fix_encoding(body, response.headers["content-type"]) 74 | etag = response.headers["etag"] 75 | @cache.write(uri, CacheEntry.new(etag, body)) unless etag.nil? 76 | end 77 | body 78 | end 79 | 80 | def fix_encoding(body, content_type) 81 | return body if content_type.nil? 82 | media_type, charset = parse_content_type(content_type) 83 | return body if charset.nil? 84 | body.force_encoding(Encoding::find(charset)).encode(Encoding::UTF_8) 85 | end 86 | 87 | def parse_content_type(value) 88 | return [nil, nil] if value.nil? || value == '' 89 | parts = value.split(/; */) 90 | return [value, nil] if parts.count < 2 91 | charset = nil 92 | parts.each do |part| 93 | fields = part.split('=') 94 | if fields.count >= 2 && fields[0] == 'charset' 95 | charset = fields[1] 96 | break 97 | end 98 | end 99 | [parts[0], charset] 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/ldclient-rb/simple_lru_cache.rb: -------------------------------------------------------------------------------- 1 | 2 | module LaunchDarkly 3 | # A non-thread-safe implementation of a LRU cache set with only add and reset methods. 4 | # Based on https://github.com/SamSaffron/lru_redux/blob/master/lib/lru_redux/cache.rb 5 | # @private 6 | class SimpleLRUCacheSet 7 | def initialize(capacity) 8 | @values = {} 9 | @capacity = capacity 10 | end 11 | 12 | # Adds a value to the cache or marks it recent if it was already there. Returns true if already there. 13 | def add(value) 14 | found = true 15 | @values.delete(value) { found = false } 16 | @values[value] = true 17 | @values.shift if @values.length > @capacity 18 | found 19 | end 20 | 21 | def clear 22 | @values = {} 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ldclient-rb/util.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "http" 3 | 4 | module LaunchDarkly 5 | # 6 | # A Result is used to reflect the outcome of any operation. 7 | # 8 | # Results can either be considered a success or a failure. 9 | # 10 | # In the event of success, the Result will contain an option, nullable value to hold any success value back to the 11 | # calling function. 12 | # 13 | # If the operation fails, the Result will contain an error describing the value. 14 | # 15 | class Result 16 | # 17 | # Create a successful result with the provided value. 18 | # 19 | # @param value [Object, nil] 20 | # @return [Result] 21 | # 22 | def self.success(value) 23 | Result.new(value) 24 | end 25 | 26 | # 27 | # Create a failed result with the provided error description. 28 | # 29 | # @param error [String] 30 | # @param exception [Exception, nil] 31 | # @return [Result] 32 | # 33 | def self.fail(error, exception = nil) 34 | Result.new(nil, error, exception) 35 | end 36 | 37 | # 38 | # Was this result successful or did it encounter an error? 39 | # 40 | # @return [Boolean] 41 | # 42 | def success? 43 | @error.nil? 44 | end 45 | 46 | # 47 | # @return [Object, nil] The value returned from the operation if it was successful; nil otherwise. 48 | # 49 | attr_reader :value 50 | 51 | # 52 | # @return [String, nil] An error description of the failure; nil otherwise 53 | # 54 | attr_reader :error 55 | 56 | # 57 | # @return [Exception, nil] An optional exception which caused the failure 58 | # 59 | attr_reader :exception 60 | 61 | private def initialize(value, error = nil, exception = nil) 62 | @value = value 63 | @error = error 64 | @exception = exception 65 | end 66 | end 67 | 68 | # @private 69 | module Util 70 | # 71 | # Append the payload filter key query parameter to the provided URI. 72 | # 73 | # @param uri [String] 74 | # @param config [Config] 75 | # @return [String] 76 | # 77 | def self.add_payload_filter_key(uri, config) 78 | return uri if config.payload_filter_key.nil? 79 | 80 | begin 81 | parsed = URI.parse(uri) 82 | new_query_params = URI.decode_www_form(String(parsed.query)) << ["filter", config.payload_filter_key] 83 | parsed.query = URI.encode_www_form(new_query_params) 84 | parsed.to_s 85 | rescue URI::InvalidURIError 86 | config.logger.warn { "[LDClient] URI could not be parsed. No filtering will be applied." } 87 | uri 88 | end 89 | end 90 | 91 | def self.new_http_client(uri_s, config) 92 | http_client_options = {} 93 | if config.socket_factory 94 | http_client_options["socket_class"] = config.socket_factory 95 | end 96 | proxy = URI.parse(uri_s).find_proxy 97 | unless proxy.nil? 98 | http_client_options["proxy"] = { 99 | proxy_address: proxy.host, 100 | proxy_port: proxy.port, 101 | proxy_username: proxy.user, 102 | proxy_password: proxy.password, 103 | } 104 | end 105 | HTTP::Client.new(http_client_options) 106 | .timeout({ 107 | read: config.read_timeout, 108 | connect: config.connect_timeout, 109 | }) 110 | .persistent(uri_s) 111 | end 112 | 113 | def self.log_exception(logger, message, exc) 114 | logger.error { "[LDClient] #{message}: #{exc.inspect}" } 115 | logger.debug { "[LDClient] Exception trace: #{exc.backtrace}" } 116 | end 117 | 118 | def self.http_error_recoverable?(status) 119 | if status >= 400 && status < 500 120 | status == 400 || status == 408 || status == 429 121 | else 122 | true 123 | end 124 | end 125 | 126 | def self.http_error_message(status, context, recoverable_message) 127 | desc = (status == 401 || status == 403) ? " (invalid SDK key)" : "" 128 | message = Util.http_error_recoverable?(status) ? recoverable_message : "giving up permanently" 129 | "HTTP error #{status}#{desc} for #{context} - #{message}" 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/ldclient-rb/version.rb: -------------------------------------------------------------------------------- 1 | module LaunchDarkly 2 | VERSION = "8.10.0" # x-release-please-version 3 | end 4 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "ruby", 5 | "bump-minor-pre-major": true, 6 | "versioning": "default", 7 | "include-component-in-tag": false, 8 | "include-v-in-tag": false, 9 | "extra-files": ["PROVENANCE.md", "lib/ldclient-rb/version.rb"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /spec/big_segment_store_spec_base.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | # Reusable test logic for testing BigSegmentStore implementations. 4 | # 5 | # Usage: 6 | # 7 | # class MyStoreTester 8 | # def initialize(options) 9 | # @options = options 10 | # end 11 | # def create_big_segment_store 12 | # MyBigSegmentStoreImplClass.new(@options) 13 | # end 14 | # def clear_data 15 | # # clear any existing data from the database, taking @options[:prefix] into account 16 | # end 17 | # def set_big_segments_metadata(metadata) 18 | # # write the metadata to the database, taking @options[:prefix] into account 19 | # end 20 | # def set_big_segments(context_hash, includes, excludes) 21 | # # update the include and exclude lists for a context, taking @options[:prefix] into account 22 | # end 23 | # end 24 | # 25 | # describe "my big segment store" do 26 | # include_examples "big_segment_store", MyStoreTester 27 | # end 28 | 29 | shared_examples "big_segment_store" do |store_tester_class| 30 | base_options = { logger: $null_logger } 31 | 32 | prefix_test_groups = [ 33 | ["with default prefix", {}], 34 | ["with specified prefix", { prefix: "testprefix" }], 35 | ] 36 | prefix_test_groups.each do |subgroup_description, prefix_options| 37 | context(subgroup_description) do 38 | # The following tests are done for each permutation of (default prefix/specified prefix) 39 | 40 | let(:store_tester) { store_tester_class.new(prefix_options.merge(base_options)) } 41 | let(:fake_context_hash) { "contexthash" } 42 | 43 | def with_empty_store 44 | store_tester.clear_data 45 | ensure_stop(store_tester.create_big_segment_store) do |store| 46 | yield store 47 | end 48 | end 49 | 50 | context "get_metadata" do 51 | it "valid value" do 52 | expected_timestamp = 1234567890 53 | with_empty_store do |store| 54 | store_tester.set_big_segments_metadata(LaunchDarkly::Interfaces::BigSegmentStoreMetadata.new(expected_timestamp)) 55 | 56 | actual = store.get_metadata 57 | 58 | expect(actual).not_to be nil 59 | expect(actual.last_up_to_date).to eq(expected_timestamp) 60 | end 61 | end 62 | 63 | it "no value" do 64 | with_empty_store do |store| 65 | actual = store.get_metadata 66 | 67 | expect(actual).not_to be nil 68 | expect(actual.last_up_to_date).to be nil 69 | end 70 | end 71 | end 72 | 73 | context "get_membership" do 74 | it "not found" do 75 | with_empty_store do |store| 76 | membership = store.get_membership(fake_context_hash) 77 | membership = {} if membership.nil? 78 | 79 | expect(membership).to eq({}) 80 | end 81 | end 82 | 83 | it "includes only" do 84 | with_empty_store do |store| 85 | store_tester.set_big_segments(fake_context_hash, %w[key1 key2], []) 86 | 87 | membership = store.get_membership(fake_context_hash) 88 | expect(membership).to eq({ "key1" => true, "key2" => true }) 89 | end 90 | end 91 | 92 | it "excludes only" do 93 | with_empty_store do |store| 94 | store_tester.set_big_segments(fake_context_hash, [], %w[key1 key2]) 95 | 96 | membership = store.get_membership(fake_context_hash) 97 | expect(membership).to eq({ "key1" => false, "key2" => false }) 98 | end 99 | end 100 | 101 | it "includes and excludes" do 102 | with_empty_store do |store| 103 | store_tester.set_big_segments(fake_context_hash, %w[key1 key2], %w[key2 key3]) 104 | 105 | membership = store.get_membership(fake_context_hash) 106 | expect(membership).to eq({ "key1" => true, "key2" => true, "key3" => false }) # include of key2 overrides exclude 107 | end 108 | end 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/capturing_logger.rb: -------------------------------------------------------------------------------- 1 | require "stringio" 2 | 3 | class CapturingLogger 4 | def initialize 5 | @output = StringIO.new 6 | @logger = Logger.new(@output) 7 | end 8 | 9 | def output 10 | @output.string 11 | end 12 | 13 | def method_missing(meth, *args, &block) 14 | @logger.send(meth, *args, &block) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module LaunchDarkly 4 | describe Config do 5 | subject { Config } 6 | describe ".initialize" do 7 | it "can be initialized with default settings" do 8 | expect(subject).to receive(:default_capacity).and_return 1234 9 | expect(subject.new.capacity).to eq 1234 10 | end 11 | it "accepts custom arguments" do 12 | expect(subject).to_not receive(:default_capacity) 13 | expect(subject.new(capacity: 50).capacity).to eq 50 14 | end 15 | it "will chomp base_url and stream_uri" do 16 | uri = "https://test.launchdarkly.com" 17 | config = subject.new(base_uri: uri + "/") 18 | expect(config.base_uri).to eq uri 19 | end 20 | end 21 | describe "@base_uri" do 22 | it "can be read" do 23 | expect(subject.new.base_uri).to eq subject.default_base_uri 24 | end 25 | end 26 | describe "@events_uri" do 27 | it "can be read" do 28 | expect(subject.new.events_uri).to eq subject.default_events_uri 29 | end 30 | end 31 | describe "@stream_uri" do 32 | it "can be read" do 33 | expect(subject.new.stream_uri).to eq subject.default_stream_uri 34 | end 35 | end 36 | describe ".default_cache_store" do 37 | it "uses Rails cache if it is available" do 38 | rails = instance_double("Rails", cache: :cache) 39 | stub_const("Rails", rails) 40 | expect(subject.default_cache_store).to eq :cache 41 | end 42 | it "uses memory store if Rails is not available" do 43 | expect(subject.default_cache_store).to be_an_instance_of ThreadSafeMemoryStore 44 | end 45 | end 46 | describe ".default_logger" do 47 | it "uses Rails logger if it is available" do 48 | rails = instance_double("Rails", logger: :logger) 49 | stub_const("Rails", rails) 50 | expect(subject.default_logger).to eq :logger 51 | end 52 | it "uses logger if Rails logger is nil" do 53 | rails = instance_double("Rails", logger: nil) 54 | stub_const("Rails", rails) 55 | expect(subject.default_logger).to be_an_instance_of Logger 56 | end 57 | it "Uses logger if Rails is not available" do 58 | expect(subject.default_logger).to be_an_instance_of Logger 59 | end 60 | end 61 | describe ".poll_interval" do 62 | it "can be set to greater than the default" do 63 | expect(subject.new(poll_interval: 31).poll_interval).to eq 31 64 | end 65 | it "cannot be set to less than the default" do 66 | expect(subject.new(poll_interval: 29).poll_interval).to eq 30 67 | end 68 | end 69 | 70 | describe ".application" do 71 | it "can be set and read" do 72 | app = { id: "my-id", version: "abcdef" } 73 | expect(subject.new(application: app).application).to eq app 74 | end 75 | 76 | it "can handle non-string values" do 77 | expect(subject.new(application: { id: 1, version: 2 }).application).to eq({ id: "1", version: "2" }) 78 | end 79 | 80 | it "will ignore invalid keys" do 81 | expect(subject.new(application: { invalid: 1, hashKey: 2 }).application).to eq({ id: "", version: "" }) 82 | end 83 | 84 | it "will drop invalid values" do 85 | [" ", "@", ":", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-a"].each do |value| 86 | expect(subject.new(logger: $null_log, application: { id: value, version: value }).application).to eq({ id: "", version: "" }) 87 | end 88 | end 89 | 90 | it "will generate correct header tag value" do 91 | [ 92 | { :id => "id", :version => "version", :expected => "application-id/id application-version/version" }, 93 | { :id => "id", :version => "", :expected => "application-id/id" }, 94 | { :id => "", :version => "version", :expected => "application-version/version" }, 95 | { :id => "", :version => "", :expected => "" }, 96 | ].each do |test_case| 97 | config = subject.new(application: { id: test_case[:id], version: test_case[:version] }) 98 | expect(Impl::Util.application_header_value(config.application)).to eq test_case[:expected] 99 | end 100 | end 101 | end 102 | describe ".omit_anonymous_contexts" do 103 | it "defaults to false" do 104 | expect(subject.new.omit_anonymous_contexts).to eq false 105 | end 106 | it "can be set to true" do 107 | expect(subject.new(omit_anonymous_contexts: true).omit_anonymous_contexts).to eq true 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/events_test_util.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/event_types" 2 | 3 | def make_eval_event(timestamp, context, key, version = nil, variation = nil, value = nil, reason = nil, 4 | default = nil, track_events = false, debug_until = nil, prereq_of = nil) 5 | LaunchDarkly::Impl::EvalEvent.new(timestamp, context, key, version, variation, value, reason, 6 | default, track_events, debug_until, prereq_of) 7 | end 8 | 9 | def make_identify_event(timestamp, context) 10 | LaunchDarkly::Impl::IdentifyEvent.new(timestamp, context) 11 | end 12 | 13 | def make_custom_event(timestamp, context, key, data = nil, metric_value = nil) 14 | LaunchDarkly::Impl::CustomEvent.new(timestamp, context, key, data, metric_value) 15 | end 16 | 17 | def with_processor_and_sender(config, starting_timestamp) 18 | sender = FakeEventSender.new 19 | timestamp = starting_timestamp 20 | ep = LaunchDarkly::EventProcessor.new("sdk_key", config, nil, nil, { 21 | event_sender: sender, 22 | timestamp_fn: proc { 23 | t = timestamp 24 | timestamp += 1 25 | t 26 | }, 27 | }) 28 | 29 | begin 30 | yield ep, sender 31 | ensure 32 | ep.stop 33 | end 34 | end 35 | 36 | class FakeEventSender 37 | attr_accessor :result 38 | attr_reader :analytics_payloads 39 | attr_reader :diagnostic_payloads 40 | 41 | def initialize 42 | @result = LaunchDarkly::Impl::EventSenderResult.new(true, false, nil) 43 | @analytics_payloads = Queue.new 44 | @diagnostic_payloads = Queue.new 45 | end 46 | 47 | def send_event_data(data, description, is_diagnostic) 48 | (is_diagnostic ? @diagnostic_payloads : @analytics_payloads).push(JSON.parse(data, symbolize_names: true)) 49 | @result 50 | end 51 | end 52 | 53 | # 54 | # Overwrites the client's event process with an instance which captures events into the FakeEventSender. 55 | # 56 | # @param client [LaunchDarkly::LDClient] 57 | # @param ep [LaunchDarkly::EventProcessor] 58 | # 59 | def override_client_event_processor(client, ep) 60 | client.instance_variable_set(:@event_processor, ep) 61 | end 62 | -------------------------------------------------------------------------------- /spec/expiring_cache_spec.rb: -------------------------------------------------------------------------------- 1 | require 'timecop' 2 | require "ldclient-rb/expiring_cache" 3 | 4 | module LaunchDarkly 5 | describe ExpiringCache do 6 | subject { ExpiringCache } 7 | 8 | before(:each) do 9 | Timecop.freeze(Time.now) 10 | end 11 | 12 | after(:each) do 13 | Timecop.return 14 | end 15 | 16 | it "evicts entries based on TTL" do 17 | c = subject.new(3, 300) 18 | c[:a] = 1 19 | c[:b] = 2 20 | 21 | Timecop.freeze(Time.now + 330) 22 | 23 | c[:c] = 3 24 | 25 | expect(c[:a]).to be nil 26 | expect(c[:b]).to be nil 27 | expect(c[:c]).to eq 3 28 | end 29 | 30 | it "evicts entries based on max size" do 31 | c = subject.new(2, 300) 32 | c[:a] = 1 33 | c[:b] = 2 34 | c[:c] = 3 35 | 36 | expect(c[:a]).to be nil 37 | expect(c[:b]).to eq 2 38 | expect(c[:c]).to eq 3 39 | end 40 | 41 | it "does not reset LRU on get" do 42 | c = subject.new(2, 300) 43 | c[:a] = 1 44 | c[:b] = 2 45 | c[:a] 46 | c[:c] = 3 47 | 48 | expect(c[:a]).to be nil 49 | expect(c[:b]).to eq 2 50 | expect(c[:c]).to eq 3 51 | end 52 | 53 | it "resets LRU on put" do 54 | c = subject.new(2, 300) 55 | c[:a] = 1 56 | c[:b] = 2 57 | c[:a] = 1 58 | c[:c] = 3 59 | 60 | expect(c[:a]).to eq 1 61 | expect(c[:b]).to be nil 62 | expect(c[:c]).to eq 3 63 | end 64 | 65 | it "resets TTL on put" do 66 | c = subject.new(3, 300) 67 | c[:a] = 1 68 | c[:b] = 2 69 | 70 | Timecop.freeze(Time.now + 330) 71 | c[:a] = 1 72 | c[:c] = 3 73 | 74 | expect(c[:a]).to eq 1 75 | expect(c[:b]).to be nil 76 | expect(c[:c]).to eq 3 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/fixtures/feature.json: -------------------------------------------------------------------------------- 1 | { 2 | "key":"test-feature-flag", 3 | "version":11, 4 | "on":true, 5 | "prerequisites":[ 6 | 7 | ], 8 | "salt":"718ea30a918a4eba8734b57ab1a93227", 9 | "sel":"fe1244e5378c4f99976c9634e33667c6", 10 | "targets":[ 11 | { 12 | "values":[ 13 | "alice" 14 | ], 15 | "variation":0 16 | }, 17 | { 18 | "values":[ 19 | "bob" 20 | ], 21 | "variation":1 22 | } 23 | ], 24 | "rules":[ 25 | 26 | ], 27 | "fallthrough":{ 28 | "variation":0 29 | }, 30 | "offVariation":1, 31 | "variations":[ 32 | true, 33 | false 34 | ], 35 | "trackEvents": true, 36 | "deleted":false 37 | } -------------------------------------------------------------------------------- /spec/fixtures/feature1.json: -------------------------------------------------------------------------------- 1 | { 2 | "key":"test-feature-flag1", 3 | "version":5, 4 | "on":false, 5 | "prerequisites":[ 6 | 7 | ], 8 | "salt":"718ea30a918a4eba8734b57ab1a93227", 9 | "sel":"fe1244e5378c4f99976c9634e33667c6", 10 | "targets":[ 11 | { 12 | "values":[ 13 | "alice" 14 | ], 15 | "variation":0 16 | }, 17 | { 18 | "values":[ 19 | "bob" 20 | ], 21 | "variation":1 22 | } 23 | ], 24 | "rules":[ 25 | 26 | ], 27 | "fallthrough":{ 28 | "variation":0 29 | }, 30 | "offVariation":1, 31 | "variations":[ 32 | true, 33 | false 34 | ], 35 | "deleted":false 36 | } -------------------------------------------------------------------------------- /spec/fixtures/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "key":"user@test.com", 3 | "custom":{ 4 | "groups":[ 5 | "microsoft", 6 | "google" 7 | ] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /spec/flags_state_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "json" 3 | 4 | module LaunchDarkly 5 | describe FeatureFlagsState do 6 | subject { FeatureFlagsState } 7 | 8 | it "can get flag value" do 9 | state = subject.new(true) 10 | flag_state = { key: 'key', value: 'value', variation: 1, reason: EvaluationReason.fallthrough(false) } 11 | state.add_flag(flag_state, false, false) 12 | 13 | expect(state.flag_value('key')).to eq 'value' 14 | end 15 | 16 | it "returns nil for unknown flag" do 17 | state = subject.new(true) 18 | 19 | expect(state.flag_value('key')).to be nil 20 | end 21 | 22 | it "can be converted to values map" do 23 | state = subject.new(true) 24 | flag_state1 = { key: 'key1', value: 'value1', variation: 0, reason: EvaluationReason.fallthrough(false) } 25 | flag_state2 = { key: 'key2', value: 'value2', variation: 1, reason: EvaluationReason.fallthrough(false) } 26 | state.add_flag(flag_state1, false, false) 27 | state.add_flag(flag_state2, false, false) 28 | 29 | expect(state.values_map).to eq({ 'key1' => 'value1', 'key2' => 'value2' }) 30 | end 31 | 32 | it "can be converted to JSON structure" do 33 | state = subject.new(true) 34 | flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: EvaluationReason.fallthrough(false) } 35 | # rubocop:disable Layout/LineLength 36 | flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: EvaluationReason.fallthrough(false) } 37 | state.add_flag(flag_state1, false, false) 38 | state.add_flag(flag_state2, false, false) 39 | 40 | result = state.as_json 41 | expect(result).to eq({ 42 | 'key1' => 'value1', 43 | 'key2' => 'value2', 44 | '$flagsState' => { 45 | 'key1' => { 46 | :variation => 0, 47 | :version => 100, 48 | }, 49 | 'key2' => { 50 | :variation => 1, 51 | :version => 200, 52 | :trackEvents => true, 53 | :debugEventsUntilDate => 1000, 54 | }, 55 | }, 56 | '$valid' => true, 57 | }) 58 | end 59 | 60 | it "can be converted to JSON string" do 61 | state = subject.new(true) 62 | flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: EvaluationReason.fallthrough(false) } 63 | # rubocop:disable Layout/LineLength 64 | flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: EvaluationReason.fallthrough(false) } 65 | state.add_flag(flag_state1, false, false) 66 | state.add_flag(flag_state2, false, false) 67 | 68 | object = state.as_json 69 | str = state.to_json 70 | expect(object.to_json).to eq(str) 71 | end 72 | 73 | it "uses our custom serializer with JSON.generate" do 74 | state = subject.new(true) 75 | flag_state1 = { key: "key1", version: 100, trackEvents: false, value: 'value1', variation: 0, reason: EvaluationReason.fallthrough(false) } 76 | # rubocop:disable Layout/LineLength 77 | flag_state2 = { key: "key2", version: 200, trackEvents: true, debugEventsUntilDate: 1000, value: 'value2', variation: 1, reason: EvaluationReason.fallthrough(false) } 78 | state.add_flag(flag_state1, false, false) 79 | state.add_flag(flag_state2, false, false) 80 | 81 | string_from_to_json = state.to_json 82 | string_from_generate = JSON.generate(state) 83 | expect(string_from_generate).to eq(string_from_to_json) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/http_util.rb: -------------------------------------------------------------------------------- 1 | require "webrick" 2 | require "webrick/httpproxy" 3 | require "webrick/https" 4 | require "stringio" 5 | require "zlib" 6 | 7 | class StubHTTPServer 8 | attr_reader :requests, :port 9 | 10 | @@next_port = 50000 11 | 12 | def initialize(enable_compression: false) 13 | @port = StubHTTPServer.next_port 14 | @enable_compression = enable_compression 15 | begin 16 | base_opts = { 17 | BindAddress: '127.0.0.1', 18 | Port: @port, 19 | AccessLog: [], 20 | Logger: NullLogger.new, 21 | RequestCallback: method(:record_request), 22 | } 23 | @server = create_server(@port, base_opts) 24 | rescue Errno::EADDRINUSE 25 | @port = StubHTTPServer.next_port 26 | retry 27 | end 28 | @requests = [] 29 | @requests_queue = Queue.new 30 | end 31 | 32 | def self.next_port 33 | p = @@next_port 34 | @@next_port = (p + 1 < 60000) ? p + 1 : 50000 35 | p 36 | end 37 | 38 | def create_server(port, base_opts) 39 | WEBrick::HTTPServer.new(base_opts) 40 | end 41 | 42 | def start 43 | Thread.new { @server.start } 44 | end 45 | 46 | def stop 47 | @server.shutdown 48 | end 49 | 50 | def base_uri 51 | URI("http://127.0.0.1:#{@port}") 52 | end 53 | 54 | def setup_response(uri_path, &action) 55 | @server.mount_proc(uri_path, action) 56 | end 57 | 58 | def setup_status_response(uri_path, status, headers={}) 59 | setup_response(uri_path) do |req, res| 60 | res.status = status 61 | headers.each { |n, v| res[n] = v } 62 | end 63 | end 64 | 65 | def setup_ok_response(uri_path, body, content_type=nil, headers={}) 66 | setup_response(uri_path) do |req, res| 67 | res.status = 200 68 | res.content_type = content_type unless content_type.nil? 69 | res.body = body 70 | headers.each { |n, v| res[n] = v } 71 | end 72 | end 73 | 74 | def record_request(req, res) 75 | @requests.push(req) 76 | @requests_queue << [req, req.body] 77 | end 78 | 79 | def await_request_with_body 80 | r = @requests_queue.pop 81 | request = r[0] 82 | body = r[1] 83 | 84 | return [request, body.to_s] unless @enable_compression 85 | 86 | gz = Zlib::GzipReader.new(StringIO.new(body.to_s)) 87 | 88 | [request, gz.read] 89 | end 90 | end 91 | 92 | class NullLogger 93 | def method_missing(*) 94 | self 95 | end 96 | end 97 | 98 | def with_server(enable_compression: false) 99 | server = StubHTTPServer.new(enable_compression: enable_compression) 100 | begin 101 | server.start 102 | yield server 103 | ensure 104 | server.stop 105 | end 106 | end 107 | 108 | class SocketFactoryFromHash 109 | def initialize(ports = {}) 110 | @ports = ports 111 | end 112 | 113 | def open(uri, timeout) 114 | TCPSocket.new '127.0.0.1', @ports[uri] 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/impl/context_spec.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/context" 2 | 3 | module LaunchDarkly 4 | module Impl 5 | describe Context do 6 | subject { Context } 7 | 8 | it "can validate kind correctly" do 9 | test_cases = [ 10 | [:user_context, Context::ERR_KIND_NON_STRING], 11 | ["kind", Context::ERR_KIND_CANNOT_BE_KIND], 12 | ["multi", Context::ERR_KIND_CANNOT_BE_MULTI], 13 | ["user@type", Context::ERR_KIND_INVALID_CHARS], 14 | ["org", nil], 15 | ] 16 | 17 | test_cases.each do |input, expected| 18 | expect(subject.validate_kind(input)).to eq(expected) 19 | end 20 | end 21 | 22 | it "can validate a key correctly" do 23 | test_cases = [ 24 | [:key, Context::ERR_KEY_NON_STRING], 25 | ["", Context::ERR_KEY_EMPTY], 26 | ["key", nil], 27 | ] 28 | 29 | test_cases.each do |input, expected| 30 | expect(subject.validate_key(input)).to eq(expected) 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/impl/data_store_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module LaunchDarkly 4 | module Impl 5 | module DataStore 6 | describe DataKind do 7 | describe "eql?" do 8 | it "constant instances are equal to themselves" do 9 | expect(LaunchDarkly::FEATURES.eql?(LaunchDarkly::FEATURES)).to be true 10 | expect(LaunchDarkly::SEGMENTS.eql?(LaunchDarkly::SEGMENTS)).to be true 11 | end 12 | 13 | it "same constructions are equal" do 14 | expect(LaunchDarkly::FEATURES.eql?(DataKind.new(namespace: "features", priority: 1))).to be true 15 | expect(DataKind.new(namespace: "features", priority: 1).eql?(DataKind.new(namespace: "features", priority: 1))).to be true 16 | 17 | expect(LaunchDarkly::SEGMENTS.eql?(DataKind.new(namespace: "segments", priority: 0))).to be true 18 | expect(DataKind.new(namespace: "segments", priority: 0).eql?(DataKind.new(namespace: "segments", priority: 0))).to be true 19 | end 20 | 21 | it "distinct namespaces are not equal" do 22 | expect(DataKind.new(namespace: "features", priority: 1).eql?(DataKind.new(namespace: "segments", priority: 1))).to be false 23 | end 24 | 25 | it "distinct priorities are not equal" do 26 | expect(DataKind.new(namespace: "features", priority: 1).eql?(DataKind.new(namespace: "features", priority: 2))).to be false 27 | expect(DataKind.new(namespace: "segments", priority: 1).eql?(DataKind.new(namespace: "segments", priority: 2))).to be false 28 | end 29 | 30 | it "handles non-DataKind objects" do 31 | ["example", true, 1, 1.0, [], {}].each do |obj| 32 | expect(LaunchDarkly::FEATURES.eql?(obj)).to be false 33 | end 34 | end 35 | end 36 | 37 | describe "hash" do 38 | it "constant instances are equal to themselves" do 39 | expect(LaunchDarkly::FEATURES.hash).to be LaunchDarkly::FEATURES.hash 40 | expect(LaunchDarkly::SEGMENTS.hash).to be LaunchDarkly::SEGMENTS.hash 41 | end 42 | 43 | it "same constructions are equal" do 44 | expect(LaunchDarkly::FEATURES.hash).to be DataKind.new(namespace: "features", priority: 1).hash 45 | expect(DataKind.new(namespace: "features", priority: 1).hash).to be DataKind.new(namespace: "features", priority: 1).hash 46 | 47 | expect(LaunchDarkly::SEGMENTS.hash).to be DataKind.new(namespace: "segments", priority: 0).hash 48 | expect(DataKind.new(namespace: "segments", priority: 0).hash).to be DataKind.new(namespace: "segments", priority: 0).hash 49 | end 50 | 51 | it "distinct namespaces are not equal" do 52 | expect(DataKind.new(namespace: "features", priority: 1).hash).not_to be DataKind.new(namespace: "segments", priority: 1).hash 53 | end 54 | 55 | it "distinct priorities are not equal" do 56 | expect(DataKind.new(namespace: "features", priority: 1).hash).not_to be DataKind.new(namespace: "features", priority: 2).hash 57 | expect(DataKind.new(namespace: "segments", priority: 1).hash).not_to be DataKind.new(namespace: "segments", priority: 2).hash 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/impl/evaluator_clause_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "impl/evaluator_spec_base" 3 | 4 | module LaunchDarkly 5 | module Impl 6 | describe "Evaluator (clauses)" do 7 | describe "evaluate", :evaluator_spec_base => true do 8 | it "can match built-in attribute" do 9 | context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' }) 10 | clause = { attribute: 'name', op: 'in', values: ['Bob'] } 11 | flag = Flags.boolean_flag_with_clauses(clause) 12 | (result, _) = basic_evaluator.evaluate(flag, context) 13 | expect(result.detail.value).to be true 14 | end 15 | 16 | it "can match custom attribute" do 17 | context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob', legs: 4 }) 18 | clause = { attribute: 'legs', op: 'in', values: [4] } 19 | flag = Flags.boolean_flag_with_clauses(clause) 20 | (result, _) = basic_evaluator.evaluate(flag, context) 21 | expect(result.detail.value).to be true 22 | end 23 | 24 | it "returns false for missing attribute" do 25 | context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' }) 26 | clause = { attribute: 'legs', op: 'in', values: [4] } 27 | flag = Flags.boolean_flag_with_clauses(clause) 28 | (result, _) = basic_evaluator.evaluate(flag, context) 29 | expect(result.detail.value).to be false 30 | end 31 | 32 | it "returns false for unknown operator" do 33 | context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' }) 34 | clause = { attribute: 'name', op: 'unknown', values: [4] } 35 | flag = Flags.boolean_flag_with_clauses(clause) 36 | (result, _) = basic_evaluator.evaluate(flag, context) 37 | expect(result.detail.value).to be false 38 | end 39 | 40 | it "does not stop evaluating rules after clause with unknown operator" do 41 | context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' }) 42 | clause0 = { attribute: 'name', op: 'unknown', values: [4] } 43 | rule0 = { clauses: [ clause0 ], variation: 1 } 44 | clause1 = { attribute: 'name', op: 'in', values: ['Bob'] } 45 | rule1 = { clauses: [ clause1 ], variation: 1 } 46 | flag = Flags.boolean_flag_with_rules(rule0, rule1) 47 | (result, _) = basic_evaluator.evaluate(flag, context) 48 | expect(result.detail.value).to be true 49 | end 50 | 51 | it "can be negated" do 52 | context = LDContext.create({ key: 'x', kind: 'user', name: 'Bob' }) 53 | clause = { attribute: 'name', op: 'in', values: ['Bob'], negate: true } 54 | flag = Flags.boolean_flag_with_clauses(clause) 55 | (result, _) = basic_evaluator.evaluate(flag, context) 56 | expect(result.detail.value).to be false 57 | end 58 | 59 | it "clause match uses context kind" do 60 | clause = { contextKind: 'company', attribute: 'name', op: 'in', values: ['Catco'] } 61 | 62 | context1 = LDContext.create({ key: 'cc', kind: 'company', name: 'Catco'}) 63 | context2 = LDContext.create({ key: 'l', kind: 'user', name: 'Lucy' }) 64 | context3 = LDContext.create_multi([context1, context2]) 65 | 66 | flag = Flags.boolean_flag_with_clauses(clause) 67 | 68 | (result, _) = basic_evaluator.evaluate(flag, context1) 69 | expect(result.detail.value).to be true 70 | (result, _) = basic_evaluator.evaluate(flag, context2) 71 | expect(result.detail.value).to be false 72 | (result, _) = basic_evaluator.evaluate(flag, context3) 73 | expect(result.detail.value).to be true 74 | end 75 | 76 | it "clause match by kind attribute" do 77 | clause = { attribute: 'kind', op: 'startsWith', values: ['a'] } 78 | 79 | context1 = LDContext.create({ key: 'key', kind: 'user' }) 80 | context2 = LDContext.create({ key: 'key', kind: 'ab' }) 81 | context3 = LDContext.create_multi( 82 | [ 83 | LDContext.create({ key: 'key', kind: 'cd' }), 84 | LDContext.create({ key: 'key', kind: 'ab' }), 85 | ] 86 | ) 87 | 88 | flag = Flags.boolean_flag_with_clauses(clause) 89 | 90 | (result, _) = basic_evaluator.evaluate(flag, context1) 91 | expect(result.detail.value).to be false 92 | (result, _) = basic_evaluator.evaluate(flag, context2) 93 | expect(result.detail.value).to be true 94 | (result, _) = basic_evaluator.evaluate(flag, context3) 95 | expect(result.detail.value).to be true 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/impl/evaluator_spec_base.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/big_segments" 2 | require "ldclient-rb/impl/model/serialization" 3 | 4 | require "model_builders" 5 | require "spec_helper" 6 | 7 | module LaunchDarkly 8 | module Impl 9 | class EvaluatorBuilder 10 | def initialize(logger) 11 | @flags = {} 12 | @segments = {} 13 | @big_segment_memberships = {} 14 | @big_segments_status = BigSegmentsStatus::HEALTHY 15 | @big_segments_queries = [] 16 | @logger = logger 17 | end 18 | 19 | def with_flag(flag) 20 | @flags[flag[:key]] = Model.deserialize(FEATURES, flag) 21 | self 22 | end 23 | 24 | def with_unknown_flag(key) 25 | @flags[key] = nil 26 | self 27 | end 28 | 29 | def with_segment(segment) 30 | @segments[segment[:key]] = Model.deserialize(SEGMENTS, segment) 31 | self 32 | end 33 | 34 | def with_unknown_segment(key) 35 | @segments[key] = nil 36 | self 37 | end 38 | 39 | def with_big_segment_for_context(context, segment, included) 40 | context_key = context.key 41 | @big_segment_memberships[context_key] = {} unless @big_segment_memberships.has_key?(context_key) 42 | @big_segment_memberships[context_key][Evaluator.make_big_segment_ref(segment)] = included 43 | self 44 | end 45 | 46 | def with_big_segments_status(status) 47 | @big_segments_status = status 48 | self 49 | end 50 | 51 | def record_big_segments_queries(destination) 52 | @big_segments_queries = destination 53 | self 54 | end 55 | 56 | def build 57 | Evaluator.new(method(:get_flag), method(:get_segment), 58 | @big_segment_memberships.empty? ? nil : method(:get_big_segments), 59 | @logger) 60 | end 61 | 62 | private def get_flag(key) 63 | raise "should not have requested flag #{key}" unless @flags.has_key?(key) 64 | @flags[key] 65 | end 66 | 67 | private def get_segment(key) 68 | raise "should not have requested segment #{key}" unless @segments.has_key?(key) 69 | @segments[key] 70 | end 71 | 72 | private def get_big_segments(user_key) 73 | raise "should not have requested big segments for #{user_key}" unless @big_segment_memberships.has_key?(user_key) 74 | @big_segments_queries << user_key 75 | BigSegmentMembershipResult.new(@big_segment_memberships[user_key], @big_segments_status) 76 | end 77 | end 78 | 79 | module EvaluatorSpecBase 80 | def user_context 81 | LDContext::create({ 82 | key: "userkey", 83 | kind: "user", 84 | email: "test@example.com", 85 | name: "Bob", 86 | }) 87 | end 88 | 89 | def logger 90 | ::Logger.new($stdout, level: ::Logger::FATAL) 91 | end 92 | 93 | def basic_evaluator 94 | EvaluatorBuilder.new(logger).build 95 | end 96 | end 97 | 98 | RSpec.configure { |c| c.include EvaluatorSpecBase, :evaluator_spec_base => true } 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/impl/event_summarizer_spec.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/event_types" 2 | 3 | require "events_test_util" 4 | require "spec_helper" 5 | require "set" 6 | 7 | module LaunchDarkly 8 | module Impl 9 | describe EventSummarizer do 10 | subject { EventSummarizer } 11 | 12 | let(:context) { LaunchDarkly::LDContext.create({ key: "key", kind: "user" }) } 13 | 14 | it "does not add identify event to summary" do 15 | es = subject.new 16 | snapshot = es.snapshot 17 | es.summarize_event({ kind: "identify", context: context }) 18 | 19 | expect(es.snapshot).to eq snapshot 20 | end 21 | 22 | it "does not add custom event to summary" do 23 | es = subject.new 24 | snapshot = es.snapshot 25 | es.summarize_event({ kind: "custom", key: "whatever", context: context }) 26 | 27 | expect(es.snapshot).to eq snapshot 28 | end 29 | 30 | it "tracks start and end dates" do 31 | es = subject.new 32 | event1 = make_eval_event(2000, context, 'flag1') 33 | event2 = make_eval_event(1000, context, 'flag1') 34 | event3 = make_eval_event(1500, context, 'flag1') 35 | es.summarize_event(event1) 36 | es.summarize_event(event2) 37 | es.summarize_event(event3) 38 | data = es.snapshot 39 | 40 | expect(data.start_date).to be 1000 41 | expect(data.end_date).to be 2000 42 | end 43 | 44 | it "counts events" do 45 | es = subject.new 46 | event1 = make_eval_event(0, context, 'key1', 11, 1, 'value1', nil, 'default1') 47 | event2 = make_eval_event(0, context, 'key1', 11, 2, 'value2', nil, 'default1') 48 | event3 = make_eval_event(0, context, 'key2', 22, 1, 'value99', nil, 'default2') 49 | event4 = make_eval_event(0, context, 'key1', 11, 1, 'value99', nil, 'default1') 50 | event5 = make_eval_event(0, context, 'badkey', nil, nil, 'default3', nil, 'default3') 51 | [event1, event2, event3, event4, event5].each { |e| es.summarize_event(e) } 52 | data = es.snapshot 53 | 54 | expected_counters = { 55 | 'key1' => EventSummaryFlagInfo.new( 56 | 'default1', { 57 | 11 => { 58 | 1 => EventSummaryFlagVariationCounter.new('value1', 2), 59 | 2 => EventSummaryFlagVariationCounter.new('value2', 1), 60 | }, 61 | }, 62 | Set.new(["user"]) 63 | ), 64 | 'key2' => EventSummaryFlagInfo.new( 65 | 'default2', { 66 | 22 => { 67 | 1 => EventSummaryFlagVariationCounter.new('value99', 1), 68 | }, 69 | }, 70 | Set.new(["user"]) 71 | ), 72 | 'badkey' => EventSummaryFlagInfo.new( 73 | 'default3', { 74 | nil => { 75 | nil => EventSummaryFlagVariationCounter.new('default3', 1), 76 | }, 77 | }, 78 | Set.new(["user"]) 79 | ), 80 | } 81 | expect(data.counters).to eq expected_counters 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/impl/flag_tracker_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "ldclient-rb/impl/flag_tracker" 3 | 4 | module LaunchDarkly 5 | module Impl 6 | describe FlagTracker do 7 | subject { FlagTracker } 8 | let(:executor) { SynchronousExecutor.new } 9 | let(:broadcaster) { Broadcaster.new(executor, $null_log) } 10 | 11 | it "can add and remove listeners as expected" do 12 | listener = ListenerSpy.new 13 | 14 | tracker = subject.new(broadcaster, Proc.new {}) 15 | tracker.add_listener(listener) 16 | 17 | broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) 18 | broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag2)) 19 | 20 | tracker.remove_listener(listener) 21 | 22 | broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag3)) 23 | 24 | expect(listener.statuses.count).to eq(2) 25 | expect(listener.statuses[0].key).to eq(:flag1) 26 | expect(listener.statuses[1].key).to eq(:flag2) 27 | end 28 | 29 | describe "flag change listener" do 30 | it "listener is notified when value changes" do 31 | responses = [:initial, :second, :second, :final] 32 | eval_fn = Proc.new { responses.shift } 33 | tracker = subject.new(broadcaster, eval_fn) 34 | 35 | listener = ListenerSpy.new 36 | tracker.add_flag_value_change_listener(:flag1, nil, listener) 37 | expect(listener.statuses.count).to eq(0) 38 | 39 | broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) 40 | expect(listener.statuses.count).to eq(1) 41 | 42 | # No change was returned here (:second -> :second), so expect no change 43 | broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) 44 | expect(listener.statuses.count).to eq(1) 45 | 46 | broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) 47 | expect(listener.statuses.count).to eq(2) 48 | 49 | expect(listener.statuses[0].key).to eq(:flag1) 50 | expect(listener.statuses[0].old_value).to eq(:initial) 51 | expect(listener.statuses[0].new_value).to eq(:second) 52 | 53 | expect(listener.statuses[1].key).to eq(:flag1) 54 | expect(listener.statuses[1].old_value).to eq(:second) 55 | expect(listener.statuses[1].new_value).to eq(:final) 56 | end 57 | 58 | it "returns a listener which we can unregister" do 59 | responses = [:initial, :second, :third] 60 | eval_fn = Proc.new { responses.shift } 61 | tracker = subject.new(broadcaster, eval_fn) 62 | 63 | listener = ListenerSpy.new 64 | created_listener = tracker.add_flag_value_change_listener(:flag1, nil, listener) 65 | expect(listener.statuses.count).to eq(0) 66 | 67 | broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) 68 | expect(listener.statuses.count).to eq(1) 69 | 70 | tracker.remove_listener(created_listener) 71 | broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(:flag1)) 72 | expect(listener.statuses.count).to eq(1) 73 | 74 | expect(listener.statuses[0].old_value).to eq(:initial) 75 | expect(listener.statuses[0].new_value).to eq(:second) 76 | end 77 | end 78 | end 79 | 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/impl/model/preprocessed_data_spec.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/model/feature_flag" 2 | require "model_builders" 3 | require "spec_helper" 4 | 5 | module LaunchDarkly 6 | module Impl 7 | module DataModelPreprocessing 8 | describe "preprocessed data is not emitted in JSON" do 9 | it "for flag" do 10 | original_flag = { 11 | key: 'flagkey', 12 | version: 1, 13 | on: true, 14 | offVariation: 0, 15 | variations: [true, false], 16 | fallthroughVariation: 1, 17 | prerequisites: [ 18 | { key: 'a', variation: 0 }, 19 | ], 20 | targets: [ 21 | { variation: 0, values: ['a'] }, 22 | ], 23 | rules: [ 24 | { 25 | variation: 0, 26 | clauses: [ 27 | { attribute: 'key', op: 'in', values: ['a'] }, 28 | ], 29 | }, 30 | ], 31 | } 32 | flag = Model::FeatureFlag.new(original_flag) 33 | json = Model.serialize(FEATURES, flag) 34 | parsed = JSON.parse(json, symbolize_names: true) 35 | expect(parsed).to eq(original_flag) 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/impl/model/serialization_spec.rb: -------------------------------------------------------------------------------- 1 | require "model_builders" 2 | require "spec_helper" 3 | 4 | module LaunchDarkly 5 | module Impl 6 | module Model 7 | describe "model serialization" do 8 | it "serializes flag" do 9 | flag = FlagBuilder.new("flagkey").version(1).build 10 | json = Model.serialize(FEATURES, flag) 11 | expect(JSON.parse(json, symbolize_names: true)).to eq flag.data 12 | end 13 | 14 | it "serializes segment" do 15 | segment = SegmentBuilder.new("segkey").version(1).build 16 | json = Model.serialize(SEGMENTS, segment) 17 | expect(JSON.parse(json, symbolize_names: true)).to eq segment.data 18 | end 19 | 20 | it "deserializes flag with no rules or prerequisites" do 21 | flag_in = { key: "flagkey", version: 1 } 22 | json = flag_in.to_json 23 | flag_out = Model.deserialize(FEATURES, json, nil) 24 | expect(flag_out.data).to eq flag_in 25 | end 26 | 27 | it "deserializes segment" do 28 | segment_in = { key: "segkey", version: 1 } 29 | json = segment_in.to_json 30 | segment_out = Model.deserialize(SEGMENTS, json, nil) 31 | expect(segment_out.data).to eq segment_in 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/impl/repeating_task_spec.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/repeating_task" 2 | 3 | require "concurrent/atomics" 4 | 5 | require "spec_helper" 6 | 7 | module LaunchDarkly 8 | module Impl 9 | describe RepeatingTask do 10 | def null_logger 11 | double.as_null_object 12 | end 13 | 14 | it "can name the task" do 15 | signal = Concurrent::Event.new 16 | task = RepeatingTask.new(0.01, 0, -> { signal.set }, null_logger, "Junie B.") 17 | 18 | expect(task.name).to eq("Junie B.") 19 | task.stop 20 | end 21 | 22 | it "does not start when created" do 23 | signal = Concurrent::Event.new 24 | task = RepeatingTask.new(0.01, 0, -> { signal.set }, null_logger, "test") 25 | begin 26 | expect(signal.wait(0.1)).to be false 27 | ensure 28 | task.stop 29 | end 30 | end 31 | 32 | it "executes until stopped" do 33 | queue = Queue.new 34 | task = RepeatingTask.new(0.1, 0, -> { queue << Time.now }, null_logger, "test") 35 | begin 36 | last = nil 37 | task.start 38 | 3.times do 39 | time = queue.pop 40 | unless last.nil? 41 | expect(time.to_f - last.to_f).to be >=(0.05) 42 | end 43 | last = time 44 | end 45 | ensure 46 | task.stop 47 | stopped_time = Time.now 48 | end 49 | no_more_items = false 50 | 2.times do 51 | begin 52 | time = queue.pop(true) 53 | expect(time.to_f).to be <=(stopped_time.to_f) 54 | rescue ThreadError 55 | no_more_items = true 56 | break 57 | end 58 | end 59 | expect(no_more_items).to be true 60 | end 61 | 62 | it "can be stopped from within the task" do 63 | counter = 0 64 | stopped = Concurrent::Event.new 65 | task = RepeatingTask.new(0.01, 0, 66 | -> { 67 | counter += 1 68 | if counter >= 2 69 | task.stop 70 | stopped.set 71 | end 72 | }, 73 | null_logger, "test") 74 | begin 75 | task.start 76 | expect(stopped.wait(0.1)).to be true 77 | expect(counter).to be 2 78 | sleep(0.1) 79 | expect(counter).to be 2 80 | ensure 81 | task.stop 82 | end 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/impl/sampler_spec.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/sampler" 2 | 3 | module LaunchDarkly 4 | module Impl 5 | describe Sampler do 6 | it "samples false for non-integer values" do 7 | sampler = Sampler.new(Random.new) 8 | ["not an int", true, 3.0].each do |value| 9 | expect(sampler.sample(value)).to be(false) 10 | end 11 | end 12 | 13 | it "non-positive ints are considered false" do 14 | sampler = Sampler.new(Random.new) 15 | (-10..0).each do |value| 16 | expect(sampler.sample(value)).to be(false) 17 | end 18 | end 19 | 20 | it "one is true" do 21 | expect(Sampler.new(Random.new).sample(1)).to be(true) 22 | end 23 | 24 | it "can control sampling ratio" do 25 | count = 0 26 | sampler = Sampler.new(Random.new(0)) 27 | sampled = 1_000.times.select { |_| sampler.sample(10) } 28 | 29 | expect(sampled.size).to eq(98) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/impl/store_client_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/impl/store_client_wrapper" 2 | require "spec_helper" 3 | 4 | module LaunchDarkly 5 | module Impl 6 | describe FeatureStoreClientWrapper do 7 | describe "store listener" do 8 | it "will not notify sink if wrapped store does not support monitoring" do 9 | store = double 10 | sink = double 11 | 12 | allow(store).to receive(:stop) 13 | allow(store).to receive(:monitoring_enabled?).and_return(false) 14 | allow(store).to receive(:init).and_raise(StandardError.new('init error')) 15 | 16 | ensure_stop(FeatureStoreClientWrapper.new(store, sink, $null_log)) do |wrapper| 17 | begin 18 | wrapper.init({}) 19 | raise "init should have raised exception" 20 | rescue StandardError 21 | # Ignored 22 | end 23 | 24 | expect(sink).not_to receive(:update_status) 25 | end 26 | end 27 | 28 | it "will not notify sink if wrapped store cannot come back online" do 29 | store = double 30 | sink = double 31 | 32 | allow(store).to receive(:stop) 33 | allow(store).to receive(:monitoring_enabled?).and_return(true) 34 | allow(store).to receive(:init).and_raise(StandardError.new('init error')) 35 | 36 | ensure_stop(FeatureStoreClientWrapper.new(store, sink, $null_log)) do |wrapper| 37 | begin 38 | wrapper.init({}) 39 | raise "init should have raised exception" 40 | rescue StandardError 41 | # Ignored 42 | end 43 | 44 | expect(sink).not_to receive(:update_status) 45 | end 46 | end 47 | 48 | it "sink will be notified when store is back online" do 49 | event = Concurrent::Event.new 50 | statuses = [] 51 | listener = CallbackListener.new(->(status) { 52 | statuses << status 53 | event.set if status.available? 54 | }) 55 | 56 | broadcaster = Broadcaster.new(SynchronousExecutor.new, $null_log) 57 | broadcaster.add_listener(listener) 58 | sink = DataStore::UpdateSink.new(broadcaster) 59 | store = double 60 | 61 | allow(store).to receive(:stop) 62 | allow(store).to receive(:monitoring_enabled?).and_return(true) 63 | allow(store).to receive(:available?).and_return(false, true) 64 | allow(store).to receive(:init).and_raise(StandardError.new('init error')) 65 | 66 | ensure_stop(FeatureStoreClientWrapper.new(store, sink, $null_log)) do |wrapper| 67 | begin 68 | wrapper.init({}) 69 | raise "init should have raised exception" 70 | rescue StandardError 71 | # Ignored 72 | end 73 | 74 | event.wait(2) 75 | 76 | expect(statuses.count).to eq(2) 77 | expect(statuses[0].available).to be false 78 | expect(statuses[1].available).to be true 79 | end 80 | end 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/impl/util_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module LaunchDarkly 4 | module Impl 5 | describe "payload filter key validation" do 6 | let(:logger) { double } 7 | 8 | it "silently discards nil" do 9 | expect(logger).not_to receive(:warn) 10 | expect(Util.validate_payload_filter_key(nil, logger)).to be_nil 11 | end 12 | 13 | [true, 1, 1.0, [], {}].each do |value| 14 | it "returns nil for invalid type #{value.class}" do 15 | expect(logger).to receive(:warn) 16 | expect(Util.validate_payload_filter_key(value, logger)).to be_nil 17 | end 18 | end 19 | 20 | [ 21 | "", 22 | "-cannot-start-with-dash", 23 | "_cannot-start-with-underscore", 24 | "-cannot-start-with-period", 25 | "no spaces for you", 26 | "org@special/characters", 27 | ].each do |value| 28 | it "returns nil for invalid value #{value}" do 29 | expect(logger).to receive(:warn) 30 | expect(Util.validate_payload_filter_key(value, logger)).to be_nil 31 | end 32 | end 33 | 34 | [ 35 | "camelCase", 36 | "snake_case", 37 | "kebab-case", 38 | "with.dots", 39 | "with_underscores", 40 | "with-hyphens", 41 | "with1234numbers", 42 | "with.many_1234-mixtures", 43 | "1start-with-number", 44 | ].each do |value| 45 | it "passes for value #{value}" do 46 | expect(logger).not_to receive(:warn) 47 | expect(Util.validate_payload_filter_key(value, logger)).to eq(value) 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/in_memory_feature_store_spec.rb: -------------------------------------------------------------------------------- 1 | require "feature_store_spec_base" 2 | require "spec_helper" 3 | 4 | module LaunchDarkly 5 | class InMemoryStoreTester 6 | def create_feature_store 7 | InMemoryFeatureStore.new 8 | end 9 | end 10 | 11 | describe InMemoryFeatureStore do 12 | subject { InMemoryFeatureStore } 13 | 14 | include_examples "any_feature_store", InMemoryStoreTester.new 15 | 16 | it "does not provide status monitoring support" do 17 | store = subject.new 18 | 19 | expect(store.monitoring_enabled?).to be false 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/integrations/consul_feature_store_spec.rb: -------------------------------------------------------------------------------- 1 | require "feature_store_spec_base" 2 | require "diplomat" 3 | require "spec_helper" 4 | 5 | # These tests will all fail if there isn't a local Consul instance running. 6 | # They can be enabled with LD_SKIP_DATABASE_TESTS=0 7 | 8 | module LaunchDarkly 9 | module Integrations 10 | class ConsulStoreTester 11 | def initialize(options) 12 | @options = options 13 | @actual_prefix = @options[:prefix] || Consul.default_prefix 14 | end 15 | 16 | def clear_data 17 | Diplomat::Kv.delete(@actual_prefix + '/', recurse: true) 18 | end 19 | 20 | def create_feature_store 21 | Consul.new_feature_store(@options) 22 | end 23 | end 24 | 25 | 26 | describe "Consul feature store" do 27 | break unless ENV['LD_SKIP_DATABASE_TESTS'] == '0' 28 | 29 | before do 30 | Diplomat.configuration = Diplomat::Configuration.new 31 | end 32 | 33 | include_examples "persistent_feature_store", ConsulStoreTester 34 | 35 | it "should have monitoring enabled and defaults to available" do 36 | tester = ConsulStoreTester.new({ logger: $null_logger }) 37 | 38 | ensure_stop(tester.create_feature_store) do |store| 39 | expect(store.monitoring_enabled?).to be true 40 | expect(store.available?).to be true 41 | end 42 | end 43 | 44 | it "can detect that a non-existent store is not available" do 45 | Diplomat.configure do |config| 46 | config.url = 'http://i-mean-what-are-the-odds:13579' 47 | config.options[:request] ||= {} 48 | # Short timeout so we don't delay the tests too long 49 | config.options[:request][:timeout] = 0.1 50 | end 51 | tester = ConsulStoreTester.new({ consul_config: Diplomat.configuration }) 52 | 53 | ensure_stop(tester.create_feature_store) do |store| 54 | expect(store.available?).to be false 55 | end 56 | end 57 | 58 | end 59 | 60 | # There isn't a Big Segments integration for Consul. 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/launchdarkly-server-sdk_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "bundler" 3 | 4 | describe LaunchDarkly do 5 | it "can be automatically loaded by Bundler.require" do 6 | ldclient_loaded = 7 | Bundler.with_unbundled_env do 8 | Kernel.system("ruby", "./spec/launchdarkly-server-sdk_spec_autoloadtest.rb") 9 | end 10 | 11 | expect(ldclient_loaded).to be true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/launchdarkly-server-sdk_spec_autoloadtest.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "bundler/inline" 3 | 4 | gemfile do 5 | gem "launchdarkly-server-sdk", path: "." 6 | end 7 | 8 | Bundler.require(:development) 9 | abort unless $LOADED_FEATURES.any? { |file| file =~ /ldclient-rb\.rb/ } 10 | -------------------------------------------------------------------------------- /spec/ldclient_listeners_spec.rb: -------------------------------------------------------------------------------- 1 | require "mock_components" 2 | require "spec_helper" 3 | 4 | module LaunchDarkly 5 | describe "LDClient event listeners/observers" do 6 | context "big_segment_store_status_provider" do 7 | it "returns unavailable status when not configured" do 8 | with_client(test_config) do |client| 9 | status = client.big_segment_store_status_provider.status 10 | expect(status.available).to be(false) 11 | expect(status.stale).to be(false) 12 | end 13 | end 14 | 15 | it "sends status updates" do 16 | store = MockBigSegmentStore.new 17 | store.setup_metadata(Time.now) 18 | big_segments_config = BigSegmentsConfig.new( 19 | store: store, 20 | status_poll_interval: 0.01 21 | ) 22 | with_client(test_config(big_segments: big_segments_config)) do |client| 23 | status1 = client.big_segment_store_status_provider.status 24 | expect(status1.available).to be(true) 25 | expect(status1.stale).to be(false) 26 | 27 | statuses = Queue.new 28 | observer = SimpleObserver.adding_to_queue(statuses) 29 | client.big_segment_store_status_provider.add_observer(observer) 30 | 31 | store.setup_metadata_error(StandardError.new("sorry")) 32 | 33 | status2 = statuses.pop 34 | expect(status2.available).to be(false) 35 | expect(status2.stale).to be(false) 36 | 37 | expect(client.big_segment_store_status_provider.status).to eq(status2) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/ldclient_migration_variation_spec.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb" 2 | require "mock_components" 3 | require "model_builders" 4 | 5 | module LaunchDarkly 6 | describe "LDClient migration variation tests" do 7 | it "returns off if default stage is invalid" do 8 | td = Integrations::TestData.data_source 9 | 10 | with_client(test_config(data_source: td)) do |client| 11 | result, tracker = client.migration_variation("flagkey", basic_context, "invalid stage should default to off") 12 | 13 | expect(result).to eq(LaunchDarkly::Migrations::STAGE_OFF) 14 | expect(tracker).not_to be_nil 15 | end 16 | end 17 | 18 | it "returns error if flag isn't found" do 19 | td = Integrations::TestData.data_source 20 | 21 | with_client(test_config(data_source: td)) do |client| 22 | result, tracker = client.migration_variation("flagkey", basic_context, LaunchDarkly::Migrations::STAGE_LIVE) 23 | 24 | expect(result).to eq(LaunchDarkly::Migrations::STAGE_LIVE) 25 | expect(tracker).not_to be_nil 26 | end 27 | end 28 | 29 | it "flag doesn't return a valid stage" do 30 | td = Integrations::TestData.data_source 31 | td.update(td.flag("flagkey").variations("value").variation_for_all(0)) 32 | 33 | with_client(test_config(data_source: td)) do |client| 34 | result, tracker = client.migration_variation("flagkey", basic_context, LaunchDarkly::Migrations::STAGE_LIVE) 35 | tracker.operation(LaunchDarkly::Migrations::OP_READ) 36 | tracker.invoked(LaunchDarkly::Migrations::ORIGIN_OLD) 37 | 38 | expect(result).to eq(LaunchDarkly::Migrations::STAGE_LIVE) 39 | expect(tracker).not_to be_nil 40 | 41 | event = tracker.build 42 | expect(event.evaluation.value).to eq(LaunchDarkly::Migrations::STAGE_LIVE.to_s) 43 | expect(event.evaluation.variation_index).to be_nil 44 | expect(event.evaluation.reason.error_kind).to eq(LaunchDarkly::EvaluationReason::ERROR_WRONG_TYPE) 45 | end 46 | end 47 | 48 | it "flag doesn't return a valid stage and default is invalid" do 49 | td = Integrations::TestData.data_source 50 | td.update(td.flag("flagkey").variations("value").variation_for_all(0)) 51 | 52 | with_client(test_config(data_source: td)) do |client| 53 | result, tracker = client.migration_variation("flagkey", basic_context, "invalid stage") 54 | tracker.operation(LaunchDarkly::Migrations::OP_READ) 55 | tracker.invoked(LaunchDarkly::Migrations::ORIGIN_OLD) 56 | 57 | expect(result).to eq(LaunchDarkly::Migrations::STAGE_OFF) 58 | expect(tracker).not_to be_nil 59 | 60 | event = tracker.build 61 | expect(event.evaluation.value).to eq(LaunchDarkly::Migrations::STAGE_OFF.to_s) 62 | expect(event.evaluation.variation_index).to be_nil 63 | expect(event.evaluation.reason.error_kind).to eq(LaunchDarkly::EvaluationReason::ERROR_WRONG_TYPE) 64 | end 65 | end 66 | 67 | it "can determine correct stage from flag" do 68 | LaunchDarkly::Migrations::VALID_STAGES.each do |stage| 69 | td = Integrations::TestData.data_source 70 | td.update(td.flag("flagkey").variations(stage).variation_for_all(0)) 71 | 72 | with_client(test_config(data_source: td)) do |client| 73 | result, tracker = client.migration_variation("flagkey", basic_context, LaunchDarkly::Migrations::STAGE_LIVE) 74 | 75 | expect(result).to eq(stage) 76 | expect(tracker).not_to be_nil 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/migrator_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'ldclient-rb/interfaces' 2 | require "mock_components" 3 | 4 | module LaunchDarkly 5 | module Migrations 6 | describe MigratorBuilder do 7 | subject { MigratorBuilder } 8 | 9 | describe "can build" do 10 | it "when properly configured" do 11 | with_client(test_config) do |client| 12 | builder = subject.new(client) 13 | builder.read(->(_) { true }, ->(_) { true }) 14 | builder.write(->(_) { true }, ->(_) { true }) 15 | migrator = builder.build 16 | 17 | expect(migrator).to be_a LaunchDarkly::Interfaces::Migrations::Migrator 18 | end 19 | end 20 | 21 | it "can modify execution order" do 22 | [MigratorBuilder::EXECUTION_PARALLEL, MigratorBuilder::EXECUTION_RANDOM, MigratorBuilder::EXECUTION_SERIAL].each do |order| 23 | with_client(test_config) do |client| 24 | builder = subject.new(client) 25 | builder.read_execution_order(order) 26 | builder.read(->(_) { true }, ->(_) { true }) 27 | builder.write(->(_) { true }, ->(_) { true }) 28 | migrator = builder.build 29 | 30 | expect(migrator).to be_a LaunchDarkly::Interfaces::Migrations::Migrator 31 | end 32 | end 33 | end 34 | end 35 | 36 | describe "will fail to build" do 37 | it "if no client is provided" do 38 | error = subject.new(nil).build 39 | 40 | expect(error).to eq("client not provided") 41 | end 42 | 43 | it "if read config isn't provided" do 44 | with_client(test_config) do |client| 45 | error = subject.new(client).build 46 | 47 | expect(error).to eq("read configuration not provided") 48 | end 49 | end 50 | 51 | it "if read config has wrong arity" do 52 | with_client(test_config) do |client| 53 | builder = subject.new(client) 54 | builder.read(-> { true }, -> { true }) 55 | error = builder.build 56 | 57 | expect(error).to eq("read configuration not provided") 58 | end 59 | end 60 | 61 | it "if read comparison has wrong arity" do 62 | with_client(test_config) do |client| 63 | builder = subject.new(client) 64 | builder.read(->(_) { true }, ->(_) { true }, ->(_) { true }) 65 | error = builder.build 66 | 67 | expect(error).to eq("read configuration not provided") 68 | end 69 | end 70 | 71 | it "if write config isn't provided" do 72 | with_client(test_config) do |client| 73 | builder = subject.new(client) 74 | builder.read(->(_) { true }, ->(_) { true }) 75 | 76 | error = builder.build 77 | expect(error).to eq("write configuration not provided") 78 | end 79 | end 80 | 81 | it "if write config has wrong arity" do 82 | with_client(test_config) do |client| 83 | builder = subject.new(client) 84 | builder.read(->(_) { true }, ->(_) { true }) 85 | builder.write(-> { true }, -> { true }) 86 | error = builder.build 87 | 88 | expect(error).to eq("write configuration not provided") 89 | end 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/mock_components.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | require "ldclient-rb/impl/big_segments" 4 | require "ldclient-rb/impl/evaluator" 5 | require "ldclient-rb/interfaces" 6 | 7 | def sdk_key 8 | "sdk-key" 9 | end 10 | 11 | def null_data 12 | LaunchDarkly::NullUpdateProcessor.new 13 | end 14 | 15 | def null_logger 16 | double.as_null_object 17 | end 18 | 19 | def base_config 20 | { 21 | data_source: null_data, 22 | send_events: false, 23 | logger: null_logger, 24 | } 25 | end 26 | 27 | def test_config(add_props = {}) 28 | LaunchDarkly::Config.new(base_config.merge(add_props)) 29 | end 30 | 31 | def with_client(config) 32 | ensure_close(LaunchDarkly::LDClient.new(sdk_key, config)) do |client| 33 | yield client 34 | end 35 | end 36 | 37 | def basic_context 38 | LaunchDarkly::LDContext::create({ "key": "user-key", kind: "user" }) 39 | end 40 | 41 | module LaunchDarkly 42 | class CapturingFeatureStore 43 | attr_reader :received_data 44 | 45 | def init(all_data) 46 | @received_data = all_data 47 | end 48 | 49 | def stop 50 | end 51 | end 52 | 53 | class MockBigSegmentStore 54 | def initialize 55 | @metadata = nil 56 | @metadata_error = nil 57 | @memberships = {} 58 | end 59 | 60 | def get_metadata 61 | raise @metadata_error unless @metadata_error.nil? 62 | @metadata 63 | end 64 | 65 | def get_membership(context_hash) 66 | @memberships[context_hash] 67 | end 68 | 69 | def stop 70 | end 71 | 72 | def setup_metadata(last_up_to_date) 73 | @metadata = Interfaces::BigSegmentStoreMetadata.new(last_up_to_date.to_f * 1000) 74 | end 75 | 76 | def setup_metadata_error(ex) 77 | @metadata_error = ex 78 | end 79 | 80 | def setup_segment_for_context(user_key, segment, included) 81 | user_hash = Impl::BigSegmentStoreManager.hash_for_context_key(user_key) 82 | @memberships[user_hash] ||= {} 83 | @memberships[user_hash][Impl::Evaluator.make_big_segment_ref(segment)] = included 84 | end 85 | end 86 | 87 | class SimpleObserver 88 | def initialize(fn) 89 | @fn = fn 90 | end 91 | 92 | def update(value) 93 | @fn.call(value) 94 | end 95 | 96 | def self.adding_to_queue(q) 97 | new(->(value) { q << value }) 98 | end 99 | end 100 | 101 | class MockHook 102 | include Interfaces::Hooks::Hook 103 | 104 | def initialize(before_evaluation, after_evaluation) 105 | @before_evaluation = before_evaluation 106 | @after_evaluation = after_evaluation 107 | end 108 | 109 | def metadata 110 | Interfaces::Hooks::Metadata.new("mock hook") 111 | end 112 | 113 | def before_evaluation(evaluation_series_context, data) 114 | @before_evaluation.call(evaluation_series_context, data) 115 | end 116 | 117 | def after_evaluation(evaluation_series_context, data, detail) 118 | @after_evaluation.call(evaluation_series_context, data, detail) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/reference_spec.rb: -------------------------------------------------------------------------------- 1 | require "ldclient-rb/reference" 2 | 3 | module LaunchDarkly 4 | describe Reference do 5 | subject { Reference } 6 | 7 | it "determines invalid formats" do 8 | [ 9 | # Empty reference failures 10 | [nil, 'empty reference'], 11 | ["", 'empty reference'], 12 | ["/", 'empty reference'], 13 | 14 | # Double or trailing slashes 15 | ["//", 'double or trailing slash'], 16 | ["/a//b", 'double or trailing slash'], 17 | ["/a/b/", 'double or trailing slash'], 18 | 19 | # Invalid escape sequence 20 | ["/a~x", 'invalid escape sequence'], 21 | ["/a~", 'invalid escape sequence'], 22 | ["/a/b~x", 'invalid escape sequence'], 23 | ["/a/b~", 'invalid escape sequence'], 24 | 25 | ].each do |(path, msg)| 26 | ref = subject.create(path) 27 | expect(ref.raw_path).to eq(path) 28 | expect(ref.error).to eq(msg) 29 | end 30 | end 31 | 32 | describe "can handle valid formats" do 33 | it "can process references without a leading slash" do 34 | %w[key kind name name/with/slashes name~0~1with-what-looks-like-escape-sequences].each do |path| 35 | ref = subject.create(path) 36 | 37 | expect(ref.raw_path).to eq(path) 38 | expect(ref.error).to be_nil 39 | expect(ref.depth).to eq(1) 40 | end 41 | end 42 | 43 | it "can handle simple references with a leading slash" do 44 | [ 45 | ["/key", :key], 46 | ["/0", :"0"], 47 | ["/name~1with~1slashes~0and~0tildes", :"name/with/slashes~and~tildes"], 48 | ].each do |(path, component)| 49 | ref = subject.create(path) 50 | 51 | expect(ref.raw_path).to eq(path) 52 | expect(ref.error).to be_nil 53 | expect(ref.depth).to eq(1) 54 | expect(ref.component(0)).to eq(component) 55 | end 56 | end 57 | 58 | it "can access sub-components of varying depths" do 59 | [ 60 | ["key", 1, 0, :key], 61 | ["/key", 1, 0, :key], 62 | 63 | ["/a/b", 2, 0, :a], 64 | ["/a/b", 2, 1, :b], 65 | 66 | ["/a~1b/c", 2, 0, :"a/b"], 67 | ["/a~0b/c", 2, 0, :"a~b"], 68 | 69 | ["/a/10/20/30x", 4, 1, :"10"], 70 | ["/a/10/20/30x", 4, 2, :"20"], 71 | ["/a/10/20/30x", 4, 3, :"30x"], 72 | 73 | # invalid arguments don't cause an error, they just return nil 74 | ["", 0, 0, nil], 75 | ["", 0, -1, nil], 76 | 77 | ["key", 1, -1, nil], 78 | ["key", 1, 1, nil], 79 | 80 | ["/key", 1, -1, nil], 81 | ["/key", 1, 1, nil], 82 | 83 | ["/a/b", 2, -1, nil], 84 | ["/a/b", 2, 2, nil], 85 | ].each do |(path, depth, index, component)| 86 | ref = subject.create(path) 87 | expect(ref.depth).to eq(depth) 88 | expect(ref.component(index)).to eq(component) 89 | end 90 | end 91 | end 92 | 93 | describe "creating literal references" do 94 | it "can create valid references" do 95 | [ 96 | %w[name name], 97 | %w[a/b a/b], 98 | %w[/a/b~c /~1a~1b~0c], 99 | %w[/ /~1], 100 | ].each do |(literal, path)| 101 | expect(subject.create_literal(literal).raw_path).to eq(subject.create(path).raw_path) 102 | end 103 | end 104 | 105 | it("can detect invalid references") do 106 | [nil, "", true].each do |value| 107 | expect(subject.create_literal(value).error).to eq('empty reference') 108 | end 109 | end 110 | end 111 | 112 | it("can handle equality comparisons") do 113 | [ 114 | [subject.create("name"), subject.create("name"), true], 115 | [subject.create("name"), subject.create("/name"), true], 116 | [subject.create("/first/name"), subject.create("/first/name"), true], 117 | [subject.create_literal("/name"), subject.create_literal("/name"), true], 118 | [subject.create_literal("/name"), subject.create("/~1name"), true], 119 | [subject.create_literal("~name"), subject.create("/~0name"), true], 120 | 121 | [subject.create("different"), subject.create("values"), false], 122 | [subject.create("name/"), subject.create("/name"), false], 123 | [subject.create("/first/name"), subject.create("/first//name"), false], 124 | 125 | ].each do |(lhs, rhs, expectation)| 126 | expect(lhs == rhs).to be expectation 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/segment_store_spec_base.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.shared_examples "segment_store" do |create_store_method| 4 | 5 | let(:segment0) { 6 | { 7 | key: "test-segment", 8 | version: 11, 9 | salt: "718ea30a918a4eba8734b57ab1a93227", 10 | rules: [], 11 | } 12 | } 13 | let(:key0) { segment0[:key].to_sym } 14 | 15 | let!(:store) do 16 | s = create_store_method.call 17 | s.init({ key0 => segment0 }) 18 | s 19 | end 20 | 21 | def new_version_plus(f, delta_version, attrs = {}) 22 | f1 = f.clone 23 | f1[:version] = f[:version] + delta_version 24 | f1.update(attrs) 25 | f1 26 | end 27 | 28 | 29 | it "is initialized" do 30 | expect(store.initialized?).to eq true 31 | end 32 | 33 | it "can get existing feature with symbol key" do 34 | expect(store.get(key0)).to eq segment0 35 | end 36 | 37 | it "can get existing feature with string key" do 38 | expect(store.get(key0.to_s)).to eq segment0 39 | end 40 | 41 | it "gets nil for nonexisting feature" do 42 | expect(store.get('nope')).to be_nil 43 | end 44 | 45 | it "can get all features" do 46 | feature1 = segment0.clone 47 | feature1[:key] = "test-feature-flag1" 48 | feature1[:version] = 5 49 | feature1[:on] = false 50 | store.upsert(:"test-feature-flag1", feature1) 51 | expect(store.all).to eq({ key0 => segment0, :"test-feature-flag1" => feature1 }) 52 | end 53 | 54 | it "can add new feature" do 55 | feature1 = segment0.clone 56 | feature1[:key] = "test-feature-flag1" 57 | feature1[:version] = 5 58 | feature1[:on] = false 59 | store.upsert(:"test-feature-flag1", feature1) 60 | expect(store.get(:"test-feature-flag1")).to eq feature1 61 | end 62 | 63 | it "can update feature with newer version" do 64 | f1 = new_version_plus(segment0, 1, { on: !segment0[:on] }) 65 | store.upsert(key0, f1) 66 | expect(store.get(key0)).to eq f1 67 | end 68 | 69 | it "cannot update feature with same version" do 70 | f1 = new_version_plus(segment0, 0, { on: !segment0[:on] }) 71 | store.upsert(key0, f1) 72 | expect(store.get(key0)).to eq segment0 73 | end 74 | 75 | it "cannot update feature with older version" do 76 | f1 = new_version_plus(segment0, -1, { on: !segment0[:on] }) 77 | store.upsert(key0, f1) 78 | expect(store.get(key0)).to eq segment0 79 | end 80 | 81 | it "can delete feature with newer version" do 82 | store.delete(key0, segment0[:version] + 1) 83 | expect(store.get(key0)).to be_nil 84 | end 85 | 86 | it "cannot delete feature with same version" do 87 | store.delete(key0, segment0[:version]) 88 | expect(store.get(key0)).to eq segment0 89 | end 90 | 91 | it "cannot delete feature with older version" do 92 | store.delete(key0, segment0[:version] - 1) 93 | expect(store.get(key0)).to eq segment0 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/simple_lru_cache_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module LaunchDarkly 4 | describe SimpleLRUCacheSet do 5 | subject { SimpleLRUCacheSet } 6 | 7 | it "retains values up to capacity" do 8 | lru = subject.new(3) 9 | expect(lru.add("a")).to be false 10 | expect(lru.add("b")).to be false 11 | expect(lru.add("c")).to be false 12 | expect(lru.add("a")).to be true 13 | expect(lru.add("b")).to be true 14 | expect(lru.add("c")).to be true 15 | end 16 | 17 | it "discards oldest value on overflow" do 18 | lru = subject.new(2) 19 | expect(lru.add("a")).to be false 20 | expect(lru.add("b")).to be false 21 | expect(lru.add("a")).to be true 22 | expect(lru.add("c")).to be false # b is discarded as oldest 23 | expect(lru.add("b")).to be false 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" if ENV['LD_ENABLE_CODE_COVERAGE'] == '1' 2 | 3 | require "ldclient-rb" 4 | 5 | $null_log = ::Logger.new($stdout) 6 | $null_log.level = ::Logger::FATAL 7 | 8 | def ensure_close(thing) 9 | begin 10 | yield thing 11 | ensure 12 | thing.close 13 | end 14 | end 15 | 16 | def ensure_stop(thing) 17 | begin 18 | yield thing 19 | ensure 20 | thing.stop 21 | end 22 | end 23 | 24 | class SynchronousExecutor 25 | def post 26 | yield 27 | end 28 | end 29 | 30 | class CallbackListener 31 | def initialize(callable) 32 | @callable = callable 33 | end 34 | 35 | def update(status) 36 | @callable.call(status) 37 | end 38 | end 39 | 40 | class ListenerSpy 41 | attr_reader :statuses 42 | 43 | def initialize 44 | @statuses = [] 45 | end 46 | 47 | def update(status) 48 | @statuses << status 49 | end 50 | end 51 | 52 | 53 | RSpec.configure do |config| 54 | config.expect_with :rspec do |expectations| 55 | expectations.max_formatted_output_length = 1000 # otherwise rspec tends to abbreviate our failure output and make it unreadable 56 | end 57 | config.before(:each) do 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/store_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module LaunchDarkly 4 | describe ThreadSafeMemoryStore do 5 | subject { ThreadSafeMemoryStore } 6 | let(:store) { subject.new } 7 | it "can read and write" do 8 | store.write("key", "value") 9 | expect(store.read("key")).to eq "value" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/stream_spec.rb: -------------------------------------------------------------------------------- 1 | require "ld-eventsource" 2 | require "model_builders" 3 | require "spec_helper" 4 | 5 | module LaunchDarkly 6 | describe StreamProcessor do 7 | subject { StreamProcessor } 8 | let(:executor) { SynchronousExecutor.new } 9 | let(:status_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } 10 | let(:flag_change_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } 11 | let(:config) { 12 | config = Config.new 13 | config.data_source_update_sink = Impl::DataSource::UpdateSink.new(config.feature_store, status_broadcaster, flag_change_broadcaster) 14 | config.data_source_update_sink.update_status(Interfaces::DataSource::Status::VALID, nil) 15 | config 16 | } 17 | let(:processor) { subject.new("sdk_key", config) } 18 | 19 | describe '#process_message' do 20 | let(:put_message) { SSE::StreamEvent.new(:put, '{"data":{"flags":{"asdf": {"key": "asdf"}},"segments":{"segkey": {"key": "segkey"}}}}') } 21 | let(:patch_flag_message) { SSE::StreamEvent.new(:patch, '{"path": "/flags/key", "data": {"key": "asdf", "version": 1}}') } 22 | let(:patch_seg_message) { SSE::StreamEvent.new(:patch, '{"path": "/segments/key", "data": {"key": "asdf", "version": 1}}') } 23 | let(:delete_flag_message) { SSE::StreamEvent.new(:delete, '{"path": "/flags/key", "version": 2}') } 24 | let(:delete_seg_message) { SSE::StreamEvent.new(:delete, '{"path": "/segments/key", "version": 2}') } 25 | let(:invalid_message) { SSE::StreamEvent.new(:put, '{Hi there}') } 26 | 27 | it "will accept PUT methods" do 28 | processor.send(:process_message, put_message) 29 | expect(config.feature_store.get(FEATURES, "asdf")).to eq(Flags.from_hash(key: "asdf")) 30 | expect(config.feature_store.get(SEGMENTS, "segkey")).to eq(Segments.from_hash(key: "segkey")) 31 | end 32 | it "will accept PATCH methods for flags" do 33 | processor.send(:process_message, patch_flag_message) 34 | expect(config.feature_store.get(FEATURES, "asdf")).to eq(Flags.from_hash(key: "asdf", version: 1)) 35 | end 36 | it "will accept PATCH methods for segments" do 37 | processor.send(:process_message, patch_seg_message) 38 | expect(config.feature_store.get(SEGMENTS, "asdf")).to eq(Segments.from_hash(key: "asdf", version: 1)) 39 | end 40 | it "will accept DELETE methods for flags" do 41 | processor.send(:process_message, patch_flag_message) 42 | processor.send(:process_message, delete_flag_message) 43 | expect(config.feature_store.get(FEATURES, "key")).to eq(nil) 44 | end 45 | it "will accept DELETE methods for segments" do 46 | processor.send(:process_message, patch_seg_message) 47 | processor.send(:process_message, delete_seg_message) 48 | expect(config.feature_store.get(SEGMENTS, "key")).to eq(nil) 49 | end 50 | it "will log a warning if the method is not recognized" do 51 | expect(processor.instance_variable_get(:@config).logger).to receive :warn 52 | processor.send(:process_message, SSE::StreamEvent.new(type: :get, data: "", id: nil)) 53 | end 54 | it "status listener will trigger error when JSON is invalid" do 55 | listener = ListenerSpy.new 56 | status_broadcaster.add_listener(listener) 57 | 58 | begin 59 | processor.send(:process_message, invalid_message) 60 | rescue 61 | # Ignored 62 | end 63 | 64 | expect(listener.statuses.count).to eq(2) 65 | expect(listener.statuses[1].state).to eq(Interfaces::DataSource::Status::INTERRUPTED) 66 | expect(listener.statuses[1].last_error.kind).to eq(Interfaces::DataSource::ErrorInfo::INVALID_DATA) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/util_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module LaunchDarkly 4 | describe Util do 5 | describe 'log_exception' do 6 | let(:logger) { double } 7 | 8 | it "logs error data" do 9 | expect(logger).to receive(:error) 10 | expect(logger).to receive(:debug) 11 | begin 12 | raise StandardError.new 'asdf' 13 | rescue StandardError => exn 14 | Util.log_exception(logger, "message", exn) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/version_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module LaunchDarkly 4 | describe LaunchDarkly do 5 | it "has a version" do 6 | expect(VERSION).to be 7 | end 8 | end 9 | end 10 | --------------------------------------------------------------------------------