diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0529a4b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +# Elixir thin client SDK + +## What did you accomplish? + +## How do we test the changes introduced in this PR? + +## Extra Notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dbc949..dee09d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,17 +3,18 @@ on: push jobs: test: runs-on: ubuntu-20.04 - name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} + name: OTP ${{matrix.versions.otp}} / Elixir ${{matrix.versions.elixir}} strategy: matrix: - otp: ['26.2.5'] - elixir: ['1.17.0'] + # Minimum and maximum supported versions + versions: [{ elixir: '1.14.0', otp: '25' }, { elixir: '1.18.0', otp: '26.2.5' }] steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: - otp-version: ${{matrix.otp}} - elixir-version: ${{matrix.elixir}} + otp-version: ${{matrix.versions.otp}} + elixir-version: ${{matrix.versions.elixir}} + - run: mix deps.unlock --all # compiles and runs tests against latest versions of dependencies - run: mix deps.get - run: mix test - run: mix dialyzer --format github diff --git a/.github/workflows/update-license-year.yml b/.github/workflows/update-license-year.yml new file mode 100644 index 0000000..199a0ef --- /dev/null +++ b/.github/workflows/update-license-year.yml @@ -0,0 +1,45 @@ +name: Update License Year + +on: + schedule: + - cron: "0 3 1 1 *" # 03:00 AM on January 1 + +permissions: + contents: write + pull-requests: write + +jobs: + test: + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set Current year + run: "echo CURRENT=$(date +%Y) >> $GITHUB_ENV" + + - name: Set Previous Year + run: "echo PREVIOUS=$(($CURRENT-1)) >> $GITHUB_ENV" + + - name: Update LICENSE + uses: jacobtomlinson/gha-find-replace@v3 + with: + find: ${{ env.PREVIOUS }} + replace: ${{ env.CURRENT }} + include: "LICENSE" + regex: false + + - name: Commit files + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git commit -m "Updated License Year" -a + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + title: Update License Year + branch: update-license diff --git a/.gitignore b/.gitignore index f1a03a3..e13d5e3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ split_thin_elixir-*.tar splitd # Ignore the splitd configuration file support/splitd.yaml + +# IDE files +/.vscode/ diff --git a/CHANGES.txt b/CHANGES.txt new file mode 100644 index 0000000..fb909ee --- /dev/null +++ b/CHANGES.txt @@ -0,0 +1,17 @@ +0.2.0 (February 14, 2025): + - Added new variations of the get treatment functions to support evaluating flags in given flag set/s: `Split.get_treatments_by_flag_set/3`, `Split.get_treatments_by_flag_sets/3`, `Split.get_treatments_with_config_by_flag_set/3`, and `Split.get_treatments_with_config_by_flag_sets/3`. + - Updated the `:socket_path` option for `Split.Supervisor.start_link/1` to be optional, defaulting to `"/var/run/splitd.sock"`. + - BREAKING CHANGES: + - Removed the `fallback_enabled` option from `Split.Supervisor.start_link/1`. Fallback behavior is now always enabled, so `Split` functions no longer return `{:error, _}` tuples but instead use the fallback value when an error occurs. + - Renamed the `Split.Treatment` struct to `Split.TreatmentWithConfig` and removed the `label`, `change_number`, and `timestamp` fields. + - Moved the `Split` struct to the new `Split.SplitView` module and updated some fields: renamed `configurations` to `configs`, `flag_sets` to `sets`, and added the `impressions_disabled` field. + - Updated the return types of `Split.get_treatment/3` and `Split.get_treatments/3` to return a treatment string and a map of treatment strings, respectively. + - Updated all `get_treatment` function signatures: removed the third argument (`bucketing_key`) and expanded the first argument (`key`) to accept a union, allowing either a string or a map with a key and optional bucketing key (`%{required(:matchingKey) => String.t(), optional(:bucketingKey) => String.t() | nil}`). + +0.1.0 (January 27, 2025): + - BREAKING CHANGES: + - Renamed `Split.Socket.Supervisor` module to `Split.Supervisor`, and updated the project structure to use a Context which is more in line to how Elixir libraries are structured (By @codeadict in https://github.com/splitio/elixir-thin-client/pull/17). + - Refactored the options passed to the Split.Supervisor.start_link function to use Keywords instead of Maps to be more in line with other Elixir libraries and common practices (By @codeadict in https://github.com/splitio/elixir-thin-client/pull/17). + +0.0.0 (January 21, 2025): + - Initial public release. diff --git a/CONTRIBUTORS-GUIDE.md b/CONTRIBUTORS-GUIDE.md new file mode 100644 index 0000000..df52810 --- /dev/null +++ b/CONTRIBUTORS-GUIDE.md @@ -0,0 +1,23 @@ +# Contributing to the Split Elixir thin client SDK + +Split SDK is an open source project and we welcome feedback and contribution. The information below describes how to build the project with your changes, run the tests, and send the Pull Request(PR). + +## Development process + +1. Fork the repository and create a topic branch from `development` branch. Please use a descriptive name for your branch. +2. Run `mix deps.get` to have the dependencies up to date. +3. While developing, use descriptive messages in your commits. Avoid short or meaningless sentences like: "fix bug". +4. Make sure to add tests for both positive and negative cases. +5. If your changes have any impact on the public API, make sure you update the type specification and documentation attributes (`@spec`, `@doc`, `@moduledoc`), as well as it's related test file. +6. Run the code formatter (`mix format`) and verify that all files are properly formatted. +7. Run the build script (`mix compile`) and the static type analysis (`mix dialyzer`) and make sure it runs with no errors. +8. Run tests (`mix test`) and make sure there are no failures. +9. `git push` your changes to GitHub within your topic branch. +10. Open a Pull Request(PR) from your forked repo and into the `development` branch of the original repository. +11. When creating your PR, please fill out all the fields of the PR template, as applicable, for the project. +12. Check for conflicts once the pull request is created to make sure your PR can be merged cleanly into `development`. +13. Keep an eye out for any feedback or comments from Split's SDK team. + +# Contact + +If you have any other questions or need to contact us directly in a private manner send us a note at sdks@split.io diff --git a/LICENSE b/LICENSE index 261eeb9..df08de3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,13 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Copyright © 2025 Split Software, Inc. - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at - 1. Definitions. + http://www.apache.org/licenses/LICENSE-2.0 - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 917aa07..d0a9e81 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,95 @@ -# SplitThinElixir +# Split SDK for Elixir -## Getting Started +[![hex.pm version](https://img.shields.io/hexpm/v/split_thin_sdk)](https://img.shields.io/hexpm/v/split_thin_sdk) [![Build Status](https://github.com/splitio/elixir-thin-client/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/splitio/elixir-thin-client/actions/workflows/ci-cd.yml) [![Greenkeeper badge](https://badges.greenkeeper.io/splitio/elixir-thin-client.svg)](https://greenkeeper.io/) -A step-by-step guide on how to integrate the Split.io thin client for Elixir into your app. +## Overview +This SDK is designed to work with Split, the platform for controlled rollouts, which serves features to your users via feature flags to manage your complete customer experience. + +[![Twitter Follow](https://img.shields.io/twitter/follow/splitsoftware.svg?style=social&label=Follow&maxAge=1529000)](https://twitter.com/intent/follow?screen_name=splitsoftware) + +## Compatibility + +The Elixir Thin Client SDK is compatible with Elixir v1.14.0 and later, and requires [Splitd daemon](https://help.split.io/hc/en-us/articles/18305269686157-Split-Daemon-splitd#local-deployment-recommended) v1.2.0 or later. + +## Getting started ### Installing from Hex.pm -The Split Elixir thin client is publisehd as a package in hex.pm. It can be installed +The Split Elixir thin client is published as a package in hex.pm. It can be installed by adding `split_thin_elixir` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:split_thin_elixir, "~> 0.1.0"} + {:split, "~> 0.2.0", hex: :split_thin_sdk} ] end ``` After adding the dependency, run `mix deps.get` to fetch the new dependency. -### Usage +### Using the SDK -In order to use the Split Thin Client, you must start the [Split Daemon (splitd)](https://help.split.io/hc/en-us/articles/18305269686157-Split-Daemon-splitd). +Below is a simple example that describes the instantiation and most basic usage of our SDK. -Then you can start the Elixir Split Thin Client, either in your supervision tree: +**NOTE:** Keep in mind that Elixir SDK requires an [Splitd daemon](https://help.split.io/hc/en-us/articles/18305269686157-Split-Daemon-splitd#local-deployment-recommended) instance running in your infrastructure to connect to, with the link type set to `unix-stream`. ```elixir -children = [ - {Split, opts} -] +# Start the SDK supervisor +Split.Supervisor.start_link(socket_path: "/var/run/splitd.sock") + +# Get treatment for a user +case Split.get_treatment(user_id, feature_flag_name) do + "on" -> + # Feature flag is enabled for this user + "off" -> + # Feature flag is disabled for this user + _ -> + # "control" treatment. For example, when feature flag is not found or Elixir SDK wasn't able to connect to Splitd +end ``` -Or by starting it manually: +Please refer to [our official docs](https://help.split.io/hc/en-us/articles/26988707417869-Elixir-Thin-Client-SDK) to learn about all the functionality provided by our SDK and the configuration options available for tailoring it to your current application setup. -```elixir -Split.Supervisor.start_link(opts) -``` +## Submitting issues -Where `opts` is a keyword list with the following options: +The Split team monitors all issues submitted to this [issue tracker](https://github.com/splitio/elixir-thin-client/issues). We encourage you to use this issue tracker to submit any bug reports, feedback, and feature enhancements. We'll do our best to respond in a timely manner. -- `:socket_path`: **REQUIRED** The path to the splitd socket file. For example `/var/run/splitd.sock`. -- `:fallback_enabled`: **OPTIONAL** A boolean that indicates wether we should return errors when RPC communication fails or falling back to a default value . Default is `false`. -- `:pool_size`: **OPTIONAL** The size of the pool of connections to the splitd daemon. Default is the number of online schedulers in the Erlang VM (See: https://www.erlang.org/doc/apps/erts/erl_cmd.html). -- `:connect_timeout`: **OPTIONAL** The timeout in milliseconds to connect to the splitd daemon. Default is `1000`. +## Contributing +Please see [Contributors Guide](CONTRIBUTORS-GUIDE.md) to find all you need to submit a Pull Request (PR). -Once you have started Split, you are ready to start interacting with the Split.io splitd's daemon. +## License +Licensed under the Apache License, Version 2.0. See: [Apache License](http://www.apache.org/licenses/). -## Testing +## About Split -### Running splitd for integration testing +Split is the leading Feature Delivery Platform for engineering teams that want to confidently deploy features as fast as they can develop them. Split’s fine-grained management, real-time monitoring, and data-driven experimentation ensure that new features will improve the customer experience without breaking or degrading performance. Companies like Twilio, Salesforce, GoDaddy and WePay trust Split to power their feature delivery. -There is a convenience makefile target to run `splitd` for integration testing. This is useful to test the client against a real split server. You will need to export the `SPLIT_API_KEY` environment variable exported in your shell to run splitd: +To learn more about Split, contact hello@split.io, or get started with feature flags for free at https://www.split.io/signup. -```sh -export SPLIT_API_KEY=your-api-key -make start_splitd -``` +Split has built and maintains SDKs for: -### Running tests +* .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://help.split.io/hc/en-us/articles/360020240172--NET-SDK) +* Android [Github](https://github.com/splitio/android-client) [Docs](https://help.split.io/hc/en-us/articles/360020343291-Android-SDK) +* Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/6495326064397-Angular-utilities) +* Elixir thin-client [Github](https://github.com/splitio/elixir-thin-client) [Docs](https://help.split.io/hc/en-us/articles/26988707417869-Elixir-Thin-Client-SDK) +* Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/8096158017165-Flutter-plugin) +* GO [Github](https://github.com/splitio/go-client) [Docs](https://help.split.io/hc/en-us/articles/360020093652-Go-SDK) +* iOS [Github](https://github.com/splitio/ios-client) [Docs](https://help.split.io/hc/en-us/articles/360020401491-iOS-SDK) +* Java [Github](https://github.com/splitio/java-client) [Docs](https://help.split.io/hc/en-us/articles/360020405151-Java-SDK) +* JavaScript [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK) +* JavaScript for Browser [Github](https://github.com/splitio/javascript-browser-client) [Docs](https://help.split.io/hc/en-us/articles/360058730852-Browser-SDK) +* Node.js [Github](https://github.com/splitio/javascript-client) [Docs](https://help.split.io/hc/en-us/articles/360020564931-Node-js-SDK) +* PHP [Github](https://github.com/splitio/php-client) [Docs](https://help.split.io/hc/en-us/articles/360020350372-PHP-SDK) +* PHP thin-client [Github](https://github.com/splitio/php-thin-client) [Docs](https://help.split.io/hc/en-us/articles/18305128673933-PHP-Thin-Client-SDK) +* Python [Github](https://github.com/splitio/python-client) [Docs](https://help.split.io/hc/en-us/articles/360020359652-Python-SDK) +* React [Github](https://github.com/splitio/react-client) [Docs](https://help.split.io/hc/en-us/articles/360038825091-React-SDK) +* React Native [Github](https://github.com/splitio/react-native-client) [Docs](https://help.split.io/hc/en-us/articles/4406066357901-React-Native-SDK) +* Redux [Github](https://github.com/splitio/redux-client) [Docs](https://help.split.io/hc/en-us/articles/360038851551-Redux-SDK) +* Ruby [Github](https://github.com/splitio/ruby-client) [Docs](https://help.split.io/hc/en-us/articles/360020673251-Ruby-SDK) -To run the tests, you can use the following command: +For a comprehensive list of open source projects visit our [Github page](https://github.com/splitio?utf8=%E2%9C%93&query=%20only%3Apublic%20). -```sh -mix test -``` +**Learn more about Split:** -Or if you want to use TDD fashion with [fswatch](https://github.com/emcrisostomo/fswatch) when test files change: - -```sh -fswatch lib test | mix test --listen-on-stdin -``` +Visit [split.io/product](https://www.split.io/product) for an overview of Split, or visit our documentation at [help.split.io](https://help.split.io) for more detailed information. diff --git a/lib/split.ex b/lib/split.ex index 28ae9ad..94c2569 100644 --- a/lib/split.ex +++ b/lib/split.ex @@ -10,34 +10,29 @@ defmodule Split do The most basic approach is to add `Split` as a child of your application's top-most supervisor, i.e. `lib/my_app/application.ex`. - ```elixir - defmodule MyApp.Application do - use Application - - def start(_type, _args) do - children = [ - # ... other children ... - {Split, [socket_path: "/var/run/split.sock", fallback_enabled: true]} - ] - - opts = [strategy: :one_for_one, name: MyApp.Supervisor] - Supervisor.start_link(children, opts) - end - end - ``` + defmodule MyApp.Application do + use Application + + def start(_type, _args) do + children = [ + # ... other children ... + {Split, [socket_path: "/var/run/split.sock"]} + ] + + opts = [strategy: :one_for_one, name: MyApp.Supervisor] + Supervisor.start_link(children, opts) + end + end You can also start `Split` dynamically by calling `Split.Supervisor.start_link/1`: - ```elixir - Split.Supervisor.start_link(opts) - ``` + Split.Supervisor.start_link(opts) ### Options `Split` takes a number of keyword arguments as options when starting. The following options are available: - - `:socket_path`: **REQUIRED** The path to the splitd socket file. For example `/var/run/splitd.sock`. - - `:fallback_enabled`: **OPTIONAL** A boolean that indicates wether we should return errors when RPC communication fails or falling back to a default value . Default is `false`. + - `:socket_path`: **OPTIONAL** The path to the splitd socket file. Default is `"/var/run/splitd.sock"`. - `:pool_size`: **OPTIONAL** The size of the pool of connections to the splitd daemon. Default is the number of online schedulers in the Erlang VM (See: https://www.erlang.org/doc/apps/erts/erl_cmd.html). - `:connect_timeout`: **OPTIONAL** The timeout in milliseconds to connect to the splitd daemon. Default is `1000`. @@ -46,46 +41,42 @@ defmodule Split do Once you have started Split, you are ready to start interacting with the Split.io splitd's daemon to access feature flags and configurations. - ```elixir - Split.get_treatment("user_key", "feature_name") - ``` + Split.get_treatment("user_key", "feature_name") """ alias Split.Telemetry alias Split.Sockets.Pool - alias Split.Treatment + alias Split.TreatmentWithConfig + alias Split.SplitView alias Split.RPC.Message alias Split.RPC.ResponseParser - @type t :: %Split{ - name: String.t(), - traffic_type: String.t(), - killed: boolean(), - treatments: [String.t()], - change_number: integer(), - configurations: map(), - default_treatment: String.t(), - flag_sets: [String.t()] - } - - @typedoc "An option that can be provided when starting `Split`." + @typedoc "An option that can be provided when starting `Split`. See [options](#module-options) for more information." @type option :: {:socket_path, String.t()} - | {:fallback_enabled, boolean()} | {:pool_size, non_neg_integer()} | {:connect_timeout, non_neg_integer()} + @typedoc "Options to start the `Split` application." @type options :: [option()] - defstruct [ - :name, - :traffic_type, - :killed, - :treatments, - :change_number, - :configurations, - :default_treatment, - :flag_sets - ] + @typedoc """ + The [traffic type identifier](https://help.split.io/hc/en-us/articles/360019916311-Traffic-types). + It can be either a string or a map with a matching key and an optional bucketing key. + """ + @type split_key :: + String.t() + | %{required(:matchingKey) => String.t(), optional(:bucketingKey) => String.t() | nil} + + @typedoc "A map of attributes to use when evaluating feature flags." + @type attributes :: %{ + optional(atom() | String.t()) => + String.t() | integer() | boolean() | [String.t() | integer()] | nil + } + + @typedoc "A map of properties to use when tracking an event." + @type properties :: %{ + optional(atom() | String.t()) => String.t() | integer() | boolean() | nil + } @doc """ Builds a child specification to use in a Supervisor. @@ -97,82 +88,318 @@ defmodule Split do @spec child_spec(options()) :: Supervisor.child_spec() defdelegate child_spec(options), to: Split.Supervisor - @spec get_treatment(String.t(), String.t(), String.t() | nil, map() | nil) :: - {:ok, Treatment.t()} | {:error, term()} - def get_treatment(user_key, feature_name, bucketing_key \\ nil, attributes \\ %{}) do + @doc """ + Gets the treatment string for a given key, feature flag name and optional attributes. + + ## Examples + + iex> Split.get_treatment("user_id", "located_in_usa") + "off" + iex> Split.get_treatment("user_id", "located_in_usa", %{country: "USA"}) + "on" + """ + @spec get_treatment(split_key(), String.t()) :: String.t() + @spec get_treatment(split_key(), String.t(), attributes()) :: String.t() + def get_treatment(key, feature_name, attributes \\ %{}) do request = Message.get_treatment( - user_key: user_key, + key: key, feature_name: feature_name, - bucketing_key: bucketing_key, attributes: attributes ) - execute_rpc(request) + execute_rpc(request) |> impression_to_treatment() end - @spec get_treatment_with_config(String.t(), String.t(), String.t() | nil, map() | nil) :: - {:ok, Treatment.t()} | {:error, term()} - def get_treatment_with_config(user_key, feature_name, bucketing_key \\ nil, attributes \\ %{}) do + @doc """ + Gets the treatment with config for a given key, feature flag name and optional attributes. + + ## Examples + + iex> Split.get_treatment_with_config("user_id", "located_in_usa") + %Split.TreatmentWithConfig{treatment: "off", config: nil} + iex> Split.get_treatment("user_id", "located_in_usa", %{country: "USA"}) + %Split.TreatmentWithConfig{treatment: "on", config: nil} + """ + @spec get_treatment_with_config(split_key(), String.t()) :: TreatmentWithConfig.t() + @spec get_treatment_with_config(split_key(), String.t(), attributes()) :: + TreatmentWithConfig.t() + def get_treatment_with_config(key, feature_name, attributes \\ %{}) do request = Message.get_treatment_with_config( - user_key: user_key, + key: key, feature_name: feature_name, - bucketing_key: bucketing_key, attributes: attributes ) - execute_rpc(request) + execute_rpc(request) |> impression_to_treatment_with_config() end - @spec get_treatments(String.t(), [String.t()], String.t() | nil, map() | nil) :: - {:ok, %{String.t() => Treatment.t()}} | {:error, term()} - def get_treatments(user_key, feature_names, bucketing_key \\ nil, attributes \\ %{}) do + @doc """ + Gets a map of feature flag names to treatments for a given key, list of feature flag names and optional attributes. + + ## Examples + + iex> Split.get_treatments("user_id", ["located_in_usa"]) + %{"located_in_usa" => "off"} + iex> Split.get_treatments("user_id", ["located_in_usa"], %{country: "USA"}) + %{"located_in_usa" => "on"} + """ + @spec get_treatments(split_key(), [String.t()]) :: %{ + String.t() => String.t() + } + @spec get_treatments(split_key(), [String.t()], attributes()) :: %{ + String.t() => String.t() + } + def get_treatments(key, feature_names, attributes \\ %{}) do request = Message.get_treatments( - user_key: user_key, + key: key, feature_names: feature_names, - bucketing_key: bucketing_key, attributes: attributes ) - execute_rpc(request) + execute_rpc(request) |> impressions_to_treatments() end - @spec get_treatments_with_config(String.t(), [String.t()], String.t() | nil, map() | nil) :: - {:ok, %{String.t() => Treatment.t()}} | {:error, term()} - def get_treatments_with_config(user_key, feature_names, bucketing_key \\ nil, attributes \\ %{}) do + @doc """ + Gets a map of feature flag names to treatments with config for a given key, list of feature flag names and optional attributes. + + ## Examples + + iex> Split.get_treatments_with_config("user_id", ["located_in_usa"]) + %{"located_in_usa" => %Split.TreatmentWithConfig{treatment: "off", config: nil}} + iex> Split.get_treatments_with_config("user_id", ["located_in_usa"], %{country: "USA"}) + %{"located_in_usa" => %Split.TreatmentWithConfig{treatment: "on", config: nil}} + """ + @spec get_treatments_with_config(split_key(), [String.t()]) :: %{ + String.t() => TreatmentWithConfig.t() + } + @spec get_treatments_with_config(split_key(), [String.t()], attributes()) :: %{ + String.t() => TreatmentWithConfig.t() + } + def get_treatments_with_config(key, feature_names, attributes \\ %{}) do request = Message.get_treatments_with_config( - user_key: user_key, + key: key, feature_names: feature_names, - bucketing_key: bucketing_key, attributes: attributes ) - execute_rpc(request) + execute_rpc(request) |> impressions_to_treatments_with_config() end - @spec track(String.t(), String.t(), String.t(), term(), map()) :: :ok | {:error, term()} - def track(user_key, traffic_type, event_type, value \\ nil, properties \\ %{}) do - request = Message.track(user_key, traffic_type, event_type, value, properties) + @doc """ + Gets a map of feature flag names to treatment strings for a given key, flag set name and optional attributes. + + ## Examples + + iex> Split.get_treatments_by_flag_set("user_id", "frontend_flags") + %{"located_in_usa" => "off"} + iex> Split.get_treatments_by_flag_set("user_id", "frontend_flags", %{country: "USA"}) + %{"located_in_usa" => "on"} + """ + @spec get_treatments_by_flag_set(split_key(), String.t()) :: %{ + String.t() => String.t() + } + @spec get_treatments_by_flag_set(split_key(), String.t(), attributes()) :: %{ + String.t() => String.t() + } + def get_treatments_by_flag_set(key, flag_set_name, attributes \\ %{}) do + request = + Message.get_treatments_by_flag_set( + key: key, + feature_name: flag_set_name, + attributes: attributes + ) + + execute_rpc(request) |> impressions_to_treatments() + end + + @doc """ + Gets a map of feature flag names to treatments with config for a given key, flag set name and optional attributes. + + ## Examples + + iex> Split.get_treatments_with_config_by_flag_set("user_id", "frontend_flags") + %{"located_in_usa" => %Split.TreatmentWithConfig{treatment: "off", config: nil}} + iex> Split.get_treatments_with_config_by_flag_set("user_id", "frontend_flags", %{country: "USA"}) + %{"located_in_usa" => %Split.TreatmentWithConfig{treatment: "on", config: nil}} + """ + @spec get_treatments_with_config_by_flag_set( + split_key(), + String.t() + ) :: + %{String.t() => TreatmentWithConfig.t()} + @spec get_treatments_with_config_by_flag_set( + split_key(), + String.t(), + attributes() + ) :: + %{String.t() => TreatmentWithConfig.t()} + def get_treatments_with_config_by_flag_set( + key, + flag_set_name, + attributes \\ %{} + ) do + request = + Message.get_treatments_with_config_by_flag_set( + key: key, + feature_name: flag_set_name, + attributes: attributes + ) + + execute_rpc(request) |> impressions_to_treatments_with_config() + end + + @doc """ + Gets a map of feature flag names to treatment strings for a given key, flag set name and optional attributes. + + ## Examples + + iex> Split.get_treatments_by_flag_sets("user_id", ["frontend_flags", "backend_flags"]) + %{"located_in_usa" => "off"} + iex> Split.get_treatments_by_flag_sets("user_id", ["frontend_flags", "backend_flags"], %{country: "USA"}) + %{"located_in_usa" => "on"} + """ + @spec get_treatments_by_flag_sets(split_key(), [String.t()]) :: + %{String.t() => String.t()} + @spec get_treatments_by_flag_sets(split_key(), [String.t()], attributes()) :: + %{String.t() => String.t()} + def get_treatments_by_flag_sets( + key, + flag_set_names, + attributes \\ %{} + ) do + request = + Message.get_treatments_by_flag_sets( + key: key, + feature_names: flag_set_names, + attributes: attributes + ) + + execute_rpc(request) |> impressions_to_treatments() + end + + @doc """ + Gets a map of feature flag names to treatments with config for a given key, flag set name and optional attributes. + + ## Examples + + iex> Split.get_treatments_with_config_by_flag_sets("user_id", ["frontend_flags", "backend_flags"]) + %{"located_in_usa" => %Split.TreatmentWithConfig{treatment: "off", config: nil}} + iex> Split.get_treatments_with_config_by_flag_sets("user_id", ["frontend_flags", "backend_flags"], %{country: "USA"}) + %{"located_in_usa" => %Split.TreatmentWithConfig{treatment: "on", config: nil}} + """ + @spec get_treatments_with_config_by_flag_sets( + split_key(), + [String.t()] + ) :: + %{String.t() => TreatmentWithConfig.t()} + @spec get_treatments_with_config_by_flag_sets( + split_key(), + [String.t()], + attributes() + ) :: + %{String.t() => TreatmentWithConfig.t()} + def get_treatments_with_config_by_flag_sets( + key, + flag_set_names, + attributes \\ %{} + ) do + request = + Message.get_treatments_with_config_by_flag_sets( + key: key, + feature_names: flag_set_names, + attributes: attributes + ) + + execute_rpc(request) |> impressions_to_treatments_with_config() + end + + @doc """ + Tracks an event for a given key, traffic type, event type, and optional numeric value and map of properties. + Returns `true` if the event was successfully tracked, or `false` otherwise, e.g. if the Split daemon is not running or cannot be reached. + + See: https://help.split.io/hc/en-us/articles/26988707417869-Elixir-Thin-Client-SDK#track + + ## Examples + + iex> Split.track("user_id", "user", "my-event") + true + iex> Split.track("user_id", "user", "my-event", 42) + true + iex> Split.track("user_id", "user", "my-event", nil, %{property1: "value1"}) + true + iex> Split.track("user_id", "user", "my-event", 42, %{property1: "value1"}) + true + """ + @spec track(split_key(), String.t(), String.t()) :: boolean() + @spec track(split_key(), String.t(), String.t(), number() | nil) :: boolean() + @spec track(split_key(), String.t(), String.t(), number() | nil, properties()) :: boolean() + def track(key, traffic_type, event_type, value \\ nil, properties \\ %{}) do + request = Message.track(key, traffic_type, event_type, value, properties) execute_rpc(request) end - @spec split_names() :: {:ok, %{split_names: String.t()}} | {:error, term()} + @doc """ + Gets the list of all feature flag names. + + ## Examples + + iex> Split.split_names() + ["located_in_usa"] + """ + @spec split_names() :: [String.t()] def split_names do request = Message.split_names() execute_rpc(request) end - @spec split(String.t()) :: {:ok, Split.t()} | {:error, term()} + @doc """ + Gets the data of a given feature flag name in `SplitView` format. + + ## Examples + + iex> Split.split("located_in_usa") + %Split.SplitView{ + name: "located_in_usa", + traffic_type: "user", + killed: false, + treatments: ["on", "off"], + change_number: 123456, + configs: %{ "on" => nil, "off" => nil }, + default_treatment: "off", + sets: ["frontend_flags"], + impressions_disabled: false + } + """ + @spec split(String.t()) :: SplitView.t() | nil def split(name) do request = Message.split(name) execute_rpc(request) end - @spec splits() :: {:ok, [Split.t()]} | {:error, term()} + @doc """ + Gets the data of all feature flags in `SplitView` format. + + ## Examples + + iex> Split.splits() + [%Split.SplitView{ + name: "located_in_usa", + traffic_type: "user", + killed: false, + treatments: ["on", "off"], + change_number: 123456, + configs: %{ "on" => nil, "off" => nil }, + default_treatment: "off", + sets: ["frontend_flags"], + impressions_disabled: false + }] + """ + @spec splits() :: [SplitView.t()] def splits do request = Message.splits() execute_rpc(request) @@ -191,15 +418,29 @@ defmodule Split do |> Pool.send_message(opts) |> ResponseParser.parse_response(request, span_context: telemetry_span_context) |> case do - :ok -> - {:ok, %{}} - - {:ok, data} = response -> + data = response -> {response, %{response: data}} - - {:error, reason} = error -> - {error, %{error: reason}} end end) end + + defp impression_to_treatment(impression) do + impression.treatment + end + + defp impression_to_treatment_with_config(impression) do + %TreatmentWithConfig{treatment: impression.treatment, config: impression.config} + end + + defp impressions_to_treatments(impressions) do + Enum.into(impressions, %{}, fn {key, impression} -> + {key, impression_to_treatment(impression)} + end) + end + + defp impressions_to_treatments_with_config(impressions) do + Enum.into(impressions, %{}, fn {key, impression} -> + {key, impression_to_treatment_with_config(impression)} + end) + end end diff --git a/lib/split/impression.ex b/lib/split/impression.ex index 228f8ed..f1f68fa 100644 --- a/lib/split/impression.ex +++ b/lib/split/impression.ex @@ -1,19 +1,41 @@ defmodule Split.Impression do - defstruct [ - :key, - :feature, - :treatment, - :label, - :change_number, - :timestamp - ] + defstruct key: nil, + bucketing_key: nil, + feature: nil, + treatment: "control", + config: nil, + label: nil, + change_number: nil, + timestamp: nil @type t :: %__MODULE__{ key: String.t(), + bucketing_key: String.t() | nil, feature: String.t(), treatment: String.t(), - label: String.t(), - change_number: integer(), - timestamp: integer() + config: String.t() | nil, + label: String.t() | nil, + change_number: integer() | nil, + timestamp: integer() | nil } + + @spec build_from_daemon_response(map(), String.t(), String.t() | nil, String.t()) :: t + def build_from_daemon_response(treatment_payload, key, bucketing_key, feature) do + treatment = treatment_payload["t"] + config = treatment_payload["c"] + label = get_in(treatment_payload, ["l", "l"]) + change_number = get_in(treatment_payload, ["l", "c"]) + timestamp = get_in(treatment_payload, ["l", "m"]) + + %Split.Impression{ + key: key, + bucketing_key: bucketing_key, + feature: feature, + treatment: treatment, + label: label, + config: config, + change_number: change_number, + timestamp: timestamp + } + end end diff --git a/lib/split/rpc/encoder.ex b/lib/split/rpc/encoder.ex index 0c98fc8..a21b0aa 100644 --- a/lib/split/rpc/encoder.ex +++ b/lib/split/rpc/encoder.ex @@ -12,8 +12,19 @@ defmodule Split.RPC.Encoder do iex> message = Message.split("test_split") ...> [_size, encoded] = Encoder.encode(message) ...> Msgpax.unpack!(encoded) - %{"a" => ["test_split"], "o" => 161, "v" => 1} + + + iex> message = Message.get_treatment(key: %{matching_key: "user_id"}, feature_name: "test_split", attributes: %{ :foo => "bar", "baz" => 1 }) + ...> [_size, encoded] = Encoder.encode(message) + ...> Msgpax.unpack!(encoded) + %{"a" => ["user_id", nil, "test_split", %{"baz" => 1, "foo" => "bar"}], "o" => 17, "v" => 1} + + + iex> message = Message.track(%{matching_key: "user_id", bucketing_key: "bucket"}, "user", "purchase", 100.5, %{ "baz" => 1, foo: "bar" }) + ...> [_size, encoded] = Encoder.encode(message) + ...> Msgpax.unpack!(encoded) + %{"a" => ["user_id", "user", "purchase", 100.5, %{"baz" => 1, "foo" => "bar"}], "o" => 128, "v" => 1} """ @spec encode(Message.t()) :: iodata() def encode(message) do diff --git a/lib/split/rpc/fallback.ex b/lib/split/rpc/fallback.ex index aa9b083..19bb59a 100644 --- a/lib/split/rpc/fallback.ex +++ b/lib/split/rpc/fallback.ex @@ -2,13 +2,12 @@ defmodule Split.RPC.Fallback do @moduledoc """ This module is used to provide default values for all Splitd RPC calls. - When a call to Splitd fails, and the SDK was initialized with `fallback_enabled`, - the fallback values are returned instead of the error received from the socket. + When a call to Splitd fails, the fallback values are returned instead of the error received from the socket. """ use Split.RPC.Opcodes alias Split.RPC.Message - alias Split.Treatment + alias Split.Impression @doc """ Provides a default value for the given RPC message. @@ -16,40 +15,39 @@ defmodule Split.RPC.Fallback do ## Examples iex> Fallback.fallback(%Message{o: 0x11}) - {:ok, %Treatment{treatment: "control", label: "fallback treatment"}} + %Impression{treatment: "control", label: "exception"} iex> Fallback.fallback(%Message{o: 0x13}) - {:ok, %Treatment{treatment: "control", label: "fallback treatment", config: nil}} + %Impression{treatment: "control", label: "exception", config: nil} iex> Fallback.fallback(%Message{ ...> o: 0x12, ...> a: ["user_key", "bucketing_key", ["feature_1", "feature_2"], %{}] ...> }) - {:ok, - %{ - "feature_1" => %Treatment{treatment: "control", label: "fallback treatment"}, - "feature_2" => %Treatment{treatment: "control", label: "fallback treatment"} - }} + %{ + "feature_1" => %Impression{treatment: "control", label: "exception"}, + "feature_2" => %Impression{treatment: "control", label: "exception"} + } iex> Fallback.fallback(%Message{o: 0x14, a: ["user_key", "bucketing_key", ["feature_a"], %{}]}) - {:ok, %{"feature_a" => %Treatment{treatment: "control", label: "fallback treatment", config: nil}}} + %{"feature_a" => %Impression{treatment: "control", label: "exception", config: nil}} iex> Fallback.fallback(%Message{o: 0xA1}) - {:ok, nil} + nil iex> Fallback.fallback(%Message{o: 0xA2}) - {:ok, []} + [] iex> Fallback.fallback(%Message{o: 0xA0}) - {:ok, %{split_names: []}} + [] iex> Fallback.fallback(%Message{o: 0x80}) - :ok + false """ - @spec fallback(Message.t()) :: {:ok, map() | Treatment.t(), list(), nil} | :ok + @spec fallback(Message.t()) :: map() | Impression.t() | list() | boolean() | nil def fallback(%Message{o: opcode}) when opcode in [@get_treatment_opcode, @get_treatment_with_config_opcode] do - {:ok, %Treatment{label: "fallback treatment"}} + %Impression{label: "exception"} end def fallback(%Message{o: opcode, a: args}) @@ -58,25 +56,36 @@ defmodule Split.RPC.Fallback do treatments = Enum.reduce(feature_names, %{}, fn feature_name, acc -> - Map.put(acc, feature_name, %Treatment{label: "fallback treatment"}) + Map.put(acc, feature_name, %Impression{label: "exception"}) end) - {:ok, treatments} + treatments + end + + def fallback(%Message{o: opcode, a: _}) + when opcode in [ + @get_treatments_by_flag_set_opcode, + @get_treatments_with_config_by_flag_set_opcode, + @get_treatments_by_flag_sets_opcode, + @get_treatments_with_config_by_flag_sets_opcode + ] do + # Empty map since we don't have a way to know the feature names + %{} end def fallback(%Message{o: @split_opcode}) do - {:ok, nil} + nil end def fallback(%Message{o: @splits_opcode}) do - {:ok, []} + [] end def fallback(%Message{o: @split_names_opcode}) do - {:ok, %{split_names: []}} + [] end def fallback(%Message{o: @track_opcode}) do - :ok + false end end diff --git a/lib/split/rpc/message.ex b/lib/split/rpc/message.ex index 434cf6a..3a74147 100644 --- a/lib/split/rpc/message.ex +++ b/lib/split/rpc/message.ex @@ -1,5 +1,5 @@ defmodule Split.RPC.Message do - @doc """ + @moduledoc """ Represents an RPC message to be sent to splitd. """ use Split.RPC.Opcodes @@ -22,16 +22,14 @@ defmodule Split.RPC.Message do } @type get_treatment_args :: - {:user_key, String.t()} + {:key, Split.split_key()} | {:feature_name, String.t()} - | {:bucketing_key, String.t() | nil} - | {:attributes, map() | nil} + | {:attributes, Split.attributes()} @type get_treatments_args :: - {:user_key, String.t()} + {:key, Split.split_key()} | {:feature_names, list(String.t())} - | {:bucketing_key, String.t() | nil} - | {:attributes, map() | nil} + | {:attributes, Split.attributes()} @doc """ Builds a message to register a client in splitd. @@ -50,13 +48,13 @@ defmodule Split.RPC.Message do ## Examples iex> Message.get_treatment( - ...> user_key: "user_key", + ...> key: %{:matching_key => "user_key", :bucketing_key => "bucketing_key"}, ...> feature_name: "feature_name", - ...> bucketing_key: "bucketing_key" + ...> attributes: %{} ...> ) %Message{a: ["user_key", "bucketing_key", "feature_name", %{}], o: 17, v: 1} - iex> Message.get_treatment(user_key: "user_key", feature_name: "feature_name") + iex> Message.get_treatment(key: "user_key", feature_name: "feature_name") %Message{a: ["user_key", nil, "feature_name", %{}], o: 17, v: 1} """ @spec get_treatment([get_treatment_args()]) :: t() @@ -70,14 +68,14 @@ defmodule Split.RPC.Message do ## Examples iex> Message.get_treatment_with_config( - ...> user_key: "user_key", + ...> key: %{:matching_key => "user_key", :bucketing_key => "bucketing_key"}, ...> feature_name: "feature_name", - ...> bucketing_key: "bucketing_key" + ...> attributes: %{"foo" => "bar", :baz => 1} ...> ) - %Message{a: ["user_key", "bucketing_key", "feature_name", %{}], o: 19, v: 1} + %Message{a: ["user_key", "bucketing_key", "feature_name", %{"foo" => "bar", :baz => 1}], o: 19, v: 1} iex> Message.get_treatment_with_config( - ...> user_key: "user_key", + ...> key: "user_key", ...> feature_name: "feature_name" ...> ) %Message{a: ["user_key", nil, "feature_name", %{}], o: 19, v: 1} @@ -93,9 +91,8 @@ defmodule Split.RPC.Message do ## Examples iex> Message.get_treatments( - ...> user_key: "user_key", - ...> feature_names: ["feature_name1", "feature_name2"], - ...> bucketing_key: "bucketing_key" + ...> key: %{:matching_key => "user_key", :bucketing_key => "bucketing_key"}, + ...> feature_names: ["feature_name1", "feature_name2"] ...> ) %Message{ a: ["user_key", "bucketing_key", ["feature_name1", "feature_name2"], %{}], @@ -104,7 +101,7 @@ defmodule Split.RPC.Message do } iex> Message.get_treatments( - ...> user_key: "user_key", + ...> key: "user_key", ...> feature_names: ["feature_name1", "feature_name2"] ...> ) %Message{a: ["user_key", nil, ["feature_name1", "feature_name2"], %{}], o: 18, v: 1} @@ -120,9 +117,8 @@ defmodule Split.RPC.Message do ## Examples iex> Message.get_treatments_with_config( - ...> user_key: "user_key", - ...> feature_names: ["feature_name1", "feature_name2"], - ...> bucketing_key: "bucketing_key" + ...> key: %{:matching_key => "user_key", :bucketing_key => "bucketing_key"}, + ...> feature_names: ["feature_name1", "feature_name2"] ...> ) %Message{ a: ["user_key", "bucketing_key", ["feature_name1", "feature_name2"], %{}], @@ -131,7 +127,7 @@ defmodule Split.RPC.Message do } iex> Message.get_treatments_with_config( - ...> user_key: "user_key", + ...> key: "user_key", ...> feature_names: ["feature_name1", "feature_name2"] ...> ) %Message{ @@ -145,6 +141,118 @@ defmodule Split.RPC.Message do treatment_payload(opts, @get_treatments_with_config_opcode, multiple: true) end + @doc """ + Builds a message to get the treatments for a flag set. + + ## Examples + + iex> Message.get_treatments_by_flag_set( + ...> key: %{:matching_key => "user_key", :bucketing_key => "bucketing_key"}, + ...> feature_name: "flag_set_name" + ...> ) + %Message{ + a: ["user_key", "bucketing_key", "flag_set_name", %{}], + o: 21, + v: 1 + } + + iex> Message.get_treatments_by_flag_set( + ...> key: "user_key", + ...> feature_name: "flag_set_name" + ...> ) + %Message{a: ["user_key", nil, "flag_set_name", %{}], o: 21, v: 1} + """ + @spec get_treatments_by_flag_set([get_treatment_args()]) :: t() + def get_treatments_by_flag_set(opts) do + treatment_payload(opts, @get_treatments_by_flag_set_opcode, multiple: false) + end + + @doc """ + Builds a message to get the treatments for a flag set with configuration. + + ## Examples + + iex> Message.get_treatments_with_config_by_flag_set( + ...> key: %{:matching_key => "user_key", :bucketing_key => "bucketing_key"}, + ...> feature_name: "flag_set_name" + ...> ) + %Message{ + a: ["user_key", "bucketing_key", "flag_set_name", %{}], + o: 22, + v: 1 + } + + iex> Message.get_treatments_with_config_by_flag_set( + ...> key: "user_key", + ...> feature_name: "flag_set_name" + ...> ) + %Message{ + a: ["user_key", nil, "flag_set_name", %{}], + o: 22, + v: 1 + } + """ + @spec get_treatments_with_config_by_flag_set([get_treatment_args()]) :: t() + def get_treatments_with_config_by_flag_set(opts) do + treatment_payload(opts, @get_treatments_with_config_by_flag_set_opcode, multiple: false) + end + + @doc """ + Builds a message to get the treatments for multiple flag sets. + + ## Examples + + iex> Message.get_treatments_by_flag_sets( + ...> key: %{:matching_key => "user_key", :bucketing_key => "bucketing_key"}, + ...> feature_names: ["flag_set_name1", "flag_set_name2"] + ...> ) + %Message{ + a: ["user_key", "bucketing_key", ["flag_set_name1", "flag_set_name2"], %{}], + o: 23, + v: 1 + } + + iex> Message.get_treatments_by_flag_sets( + ...> key: "user_key", + ...> feature_names: ["flag_set_name1", "flag_set_name2"] + ...> ) + %Message{a: ["user_key", nil, ["flag_set_name1", "flag_set_name2"], %{}], o: 23, v: 1} + """ + @spec get_treatments_by_flag_sets([get_treatments_args()]) :: t() + def get_treatments_by_flag_sets(opts) do + treatment_payload(opts, @get_treatments_by_flag_sets_opcode, multiple: true) + end + + @doc """ + Builds a message to get the treatments for multiple flag sets with configuration. + + ## Examples + + iex> Message.get_treatments_with_config_by_flag_sets( + ...> key: %{:matching_key => "user_key", :bucketing_key => "bucketing_key"}, + ...> feature_names: ["flag_set_name1", "flag_set_name2"] + ...> ) + %Message{ + a: ["user_key", "bucketing_key", ["flag_set_name1", "flag_set_name2"], %{}], + o: 24, + v: 1 + } + + iex> Message.get_treatments_with_config_by_flag_sets( + ...> key: %{:matching_key => "user_key"}, + ...> feature_names: ["flag_set_name1", "flag_set_name2"] + ...> ) + %Message{ + a: ["user_key", nil, ["flag_set_name1", "flag_set_name2"], %{}], + o: 24, + v: 1 + } + """ + @spec get_treatments_with_config_by_flag_sets([get_treatments_args()]) :: t() + def get_treatments_with_config_by_flag_sets(opts) do + treatment_payload(opts, @get_treatments_with_config_by_flag_sets_opcode, multiple: true) + end + @doc """ Builds a message to return information about an specific split (feature flag). @@ -183,7 +291,7 @@ defmodule Split.RPC.Message do ## Examples - iex> Message.track("user_key", "traffic_type", "my_event", 1.5, %{foo: "bar"}) + iex> Message.track("user_key", "traffic_type", "my_event", 1.5, %{:foo => "bar"}) %Message{ v: 1, o: 128, @@ -193,11 +301,21 @@ defmodule Split.RPC.Message do iex> Message.track("user_key", "traffic_type", "my_event") %Message{v: 1, o: 128, a: ["user_key", "traffic_type", "my_event", nil, %{}]} """ - @spec track(String.t(), String.t(), String.t(), any(), map()) :: t() - def track(user_key, traffic_type, event_type, value \\ nil, properties \\ %{}) do + @spec track(Split.split_key(), String.t(), String.t()) :: t() + @spec track(Split.split_key(), String.t(), String.t(), number() | nil) :: t() + @spec track(Split.split_key(), String.t(), String.t(), number() | nil, Split.properties()) :: + t() + def track(key, traffic_type, event_type, value \\ nil, properties \\ %{}) do + matching_key = + if is_map(key) do + key.matching_key + else + key + end + %__MODULE__{ o: @track_opcode, - a: [user_key, traffic_type, event_type, value, properties] + a: [matching_key, traffic_type, event_type, value, properties] } end @@ -218,6 +336,18 @@ defmodule Split.RPC.Message do iex> Message.opcode_to_rpc_name(@get_treatments_with_config_opcode) :get_treatments_with_config + iex> Message.opcode_to_rpc_name(@get_treatments_by_flag_set_opcode) + :get_treatments_by_flag_set + + iex> Message.opcode_to_rpc_name(@get_treatments_with_config_by_flag_set_opcode) + :get_treatments_with_config_by_flag_set + + iex> Message.opcode_to_rpc_name(@get_treatments_by_flag_sets_opcode) + :get_treatments_by_flag_sets + + iex> Message.opcode_to_rpc_name(@get_treatments_with_config_by_flag_sets_opcode) + :get_treatments_with_config_by_flag_sets + iex> Message.opcode_to_rpc_name(@split_opcode) :split @@ -235,6 +365,16 @@ defmodule Split.RPC.Message do def opcode_to_rpc_name(@get_treatments_opcode), do: :get_treatments def opcode_to_rpc_name(@get_treatment_with_config_opcode), do: :get_treatment_with_config def opcode_to_rpc_name(@get_treatments_with_config_opcode), do: :get_treatments_with_config + def opcode_to_rpc_name(@get_treatments_by_flag_set_opcode), do: :get_treatments_by_flag_set + + def opcode_to_rpc_name(@get_treatments_with_config_by_flag_set_opcode), + do: :get_treatments_with_config_by_flag_set + + def opcode_to_rpc_name(@get_treatments_by_flag_sets_opcode), do: :get_treatments_by_flag_sets + + def opcode_to_rpc_name(@get_treatments_with_config_by_flag_sets_opcode), + do: :get_treatments_with_config_by_flag_sets + def opcode_to_rpc_name(@split_opcode), do: :split def opcode_to_rpc_name(@splits_opcode), do: :splits def opcode_to_rpc_name(@split_names_opcode), do: :split_names @@ -244,15 +384,22 @@ defmodule Split.RPC.Message do features_key = if Keyword.get(opts, :multiple, false), do: :feature_names, else: :feature_name - user_key = Keyword.fetch!(data, :user_key) + key = Keyword.fetch!(data, :key) + + {matching_key, bucketing_key} = + if is_map(key) do + {key.matching_key, Map.get(key, :bucketing_key, nil)} + else + {key, nil} + end + feature_name = Keyword.fetch!(data, features_key) - bucketing_key = Keyword.get(data, :bucketing_key, nil) attributes = Keyword.get(data, :attributes, %{}) %__MODULE__{ o: opcode, a: [ - user_key, + matching_key, bucketing_key, feature_name, attributes diff --git a/lib/split/rpc/opcodes.ex b/lib/split/rpc/opcodes.ex index 7ece6f4..b9c1e83 100644 --- a/lib/split/rpc/opcodes.ex +++ b/lib/split/rpc/opcodes.ex @@ -8,6 +8,10 @@ defmodule Split.RPC.Opcodes do @get_treatments_opcode 0x12 @get_treatment_with_config_opcode 0x13 @get_treatments_with_config_opcode 0x14 + @get_treatments_by_flag_set_opcode 0x15 + @get_treatments_with_config_by_flag_set_opcode 0x16 + @get_treatments_by_flag_sets_opcode 0x17 + @get_treatments_with_config_by_flag_sets_opcode 0x18 @split_opcode 0xA1 @splits_opcode 0xA2 @split_names_opcode 0xA0 @@ -19,6 +23,10 @@ defmodule Split.RPC.Opcodes do @get_treatment_with_config_opcode, @get_treatments_opcode, @get_treatments_with_config_opcode, + @get_treatments_by_flag_set_opcode, + @get_treatments_with_config_by_flag_set_opcode, + @get_treatments_by_flag_sets_opcode, + @get_treatments_with_config_by_flag_sets_opcode, @split_opcode, @splits_opcode, @split_names_opcode, diff --git a/lib/split/rpc/response_parser.ex b/lib/split/rpc/response_parser.ex index 78422c9..b2f747c 100644 --- a/lib/split/rpc/response_parser.ex +++ b/lib/split/rpc/response_parser.ex @@ -8,7 +8,8 @@ defmodule Split.RPC.ResponseParser do alias Split.RPC.Fallback alias Split.RPC.Message alias Split.Telemetry - alias Split.Treatment + alias Split.Impression + alias Split.SplitView @type splitd_response :: {:ok, map()} | {:error, term()} @@ -17,11 +18,8 @@ defmodule Split.RPC.ResponseParser do """ @spec parse_response(response :: splitd_response(), request :: Message.t(), [ {:span_context, reference()} | {:span_context, nil} - ]) :: - :ok - | {:ok, map() | list() | Treatment.t() | Split.t() | nil} - | {:error, term()} - | :error + ]) :: map() | list() | Impression.t() | SplitView.t() | boolean() | nil + def parse_response(response, original_request, opts \\ []) def parse_response( @@ -33,12 +31,15 @@ defmodule Split.RPC.ResponseParser do _opts ) when opcode in [@get_treatment_opcode, @get_treatment_with_config_opcode] do - treatment = Treatment.build_from_daemon_response(treatment_data) - user_key = Enum.at(args, 0) + key = Enum.at(args, 0) + bucketing_key = Enum.at(args, 1) feature_name = Enum.at(args, 2) - Telemetry.send_impression(user_key, feature_name, treatment) - {:ok, treatment} + impression = + Impression.build_from_daemon_response(treatment_data, key, bucketing_key, feature_name) + + Telemetry.send_impression(impression) + impression end def parse_response( @@ -50,17 +51,51 @@ defmodule Split.RPC.ResponseParser do _opts ) when opcode in [@get_treatments_opcode, @get_treatments_with_config_opcode] do - treatments = Enum.map(treatments, &Treatment.build_from_daemon_response/1) - user_key = Enum.at(args, 0) + key = Enum.at(args, 0) + bucketing_key = Enum.at(args, 1) feature_names = Enum.at(args, 2) - mapped_treatments = + impressions = Enum.zip_reduce(feature_names, treatments, %{}, fn feature_name, treatment, acc -> - Telemetry.send_impression(user_key, feature_name, treatment) - Map.put(acc, feature_name, treatment) + impression = + Impression.build_from_daemon_response(treatment, key, bucketing_key, feature_name) + + Telemetry.send_impression(impression) + Map.put(acc, feature_name, impression) + end) + + impressions + end + + def parse_response( + {:ok, %{"s" => @status_ok, "p" => %{"r" => treatments}}}, + %Message{ + o: opcode, + a: args + }, + _opts + ) + when opcode in [ + @get_treatments_by_flag_set_opcode, + @get_treatments_with_config_by_flag_set_opcode, + @get_treatments_by_flag_sets_opcode, + @get_treatments_with_config_by_flag_sets_opcode + ] do + key = Enum.at(args, 0) + bucketing_key = Enum.at(args, 1) + + impressions = + treatments + |> Enum.map(fn {feature_name, treatment} -> + impression = + Impression.build_from_daemon_response(treatment, key, bucketing_key, feature_name) + + Telemetry.send_impression(impression) + {feature_name, impression} end) + |> Map.new() - {:ok, mapped_treatments} + impressions end def parse_response( @@ -68,7 +103,7 @@ defmodule Split.RPC.ResponseParser do %Message{o: @split_opcode}, _opts ) do - {:ok, parse_split(payload)} + parse_split(payload) end def parse_response( @@ -78,7 +113,7 @@ defmodule Split.RPC.ResponseParser do }, _opts ) do - {:ok, %{split_names: split_names}} + split_names end def parse_response( @@ -93,7 +128,7 @@ defmodule Split.RPC.ResponseParser do [parse_split(split) | acc] end) - {:ok, splits} + splits end def parse_response( @@ -104,9 +139,9 @@ defmodule Split.RPC.ResponseParser do _opts ) do if tracked? do - :ok + true else - :error + false end end @@ -120,7 +155,7 @@ defmodule Split.RPC.ResponseParser do response: inspect(raw_response) ) - maybe_fallback({:error, :splitd_internal_error}, message, opts) + fallback(message, opts) end def parse_response({:ok, raw_response}, %Message{} = message, opts) do @@ -129,7 +164,7 @@ defmodule Split.RPC.ResponseParser do response: inspect(raw_response) ) - maybe_fallback({:error, :splitd_parse_error}, message, opts) + fallback(message, opts) end def parse_response({:error, reason}, request, opts) do @@ -138,35 +173,32 @@ defmodule Split.RPC.ResponseParser do reason: inspect(reason) ) - maybe_fallback({:error, reason}, request, opts) + fallback(request, opts) end - defp maybe_fallback(response, original_request, opts) do - if :persistent_term.get(:splitd_fallback_enabled, false) do - fallback_response = Fallback.fallback(original_request) - - if Keyword.has_key?(opts, :span_context) do - Telemetry.span_event([:rpc, :fallback], opts[:span_context], %{ - response: fallback_response - }) - end + defp fallback(original_request, opts) do + fallback_response = Fallback.fallback(original_request) - fallback_response - else - response + if Keyword.has_key?(opts, :span_context) do + Telemetry.span_event([:rpc, :fallback], opts[:span_context], %{ + response: fallback_response + }) end + + fallback_response end defp parse_split(payload) do - %Split{ + %SplitView{ name: Map.get(payload, "n", nil), traffic_type: payload["t"], killed: payload["k"], treatments: payload["s"], change_number: payload["c"], - configurations: payload["f"], + configs: payload["f"], default_treatment: payload["d"], - flag_sets: payload["e"] + sets: payload["e"], + impressions_disabled: payload["i"] || false } end end diff --git a/lib/split/sockets/pool.ex b/lib/split/sockets/pool.ex index a3bb3c8..328b812 100644 --- a/lib/split/sockets/pool.ex +++ b/lib/split/sockets/pool.ex @@ -17,15 +17,13 @@ defmodule Split.Sockets.Pool do end def start_link(opts) do - fallback_enabled = Keyword.get(opts, :fallback_enabled, false) - :persistent_term.put(:splitd_fallback_enabled, fallback_enabled) - + socket_path = Keyword.get(opts, :socket_path, "/var/run/splitd.sock") pool_name = Keyword.get(opts, :pool_name, __MODULE__) pool_size = Keyword.get(opts, :pool_size, System.schedulers_online()) opts = opts - |> Keyword.put_new(:fallback_enabled, fallback_enabled) + |> Keyword.put_new(:socket_path, socket_path) |> Keyword.put_new(:pool_size, pool_size) |> Keyword.put_new(:pool_name, pool_name) diff --git a/lib/split/split_view.ex b/lib/split/split_view.ex new file mode 100644 index 0000000..2a904bf --- /dev/null +++ b/lib/split/split_view.ex @@ -0,0 +1,40 @@ +defmodule Split.SplitView do + @moduledoc """ + This module defines a struct that contains information about a feature flag. + + ## Fields + * `:name` - The name of the feature flag + * `:traffic_type` - The traffic type of the feature flag + * `:killed` - A boolean that indicates if the feature flag is killed + * `:treatments` - The list of treatments of the feature flag + * `:change_number` - The change number of the feature flag + * `:configs` - The map of treatments and their configurations + * `:default_treatment` - The default treatment of the feature flag + * `:sets` - The list of flag sets that the feature flag belongs to + * `:impressions_disabled` - A boolean that indicates if the tracking of impressions is disabled + """ + + defstruct [ + :name, + :traffic_type, + :killed, + :treatments, + :change_number, + :configs, + :default_treatment, + :sets, + :impressions_disabled + ] + + @type t :: %__MODULE__{ + name: String.t(), + traffic_type: String.t(), + killed: boolean(), + treatments: [String.t()], + change_number: integer(), + configs: %{String.t() => String.t() | nil}, + default_treatment: String.t(), + sets: [String.t()], + impressions_disabled: boolean() + } +end diff --git a/lib/split/supervisor.ex b/lib/split/supervisor.ex index 9dc20b4..41ff8cf 100644 --- a/lib/split/supervisor.ex +++ b/lib/split/supervisor.ex @@ -1,4 +1,8 @@ defmodule Split.Supervisor do + @moduledoc """ + The supervisor for the Split SDK. + """ + use GenServer alias Split.Sockets.Pool @@ -7,8 +11,9 @@ defmodule Split.Supervisor do {:ok, init_arg} end - @spec start_link(keyword()) :: Supervisor.on_start() - def start_link(opts) do + @spec start_link() :: Supervisor.on_start() + @spec start_link(Split.options()) :: Supervisor.on_start() + def start_link(opts \\ []) do child = {Pool, opts} Supervisor.start_link([child], strategy: :one_for_one) end diff --git a/lib/split/telemetry.ex b/lib/split/telemetry.ex index 1b66770..b4b7a1f 100644 --- a/lib/split/telemetry.ex +++ b/lib/split/telemetry.ex @@ -184,7 +184,7 @@ defmodule Split.Telemetry do * `change_number` - The change number of the treatment. * `timestamp` - The timestamp of the treatment assignment. """ - alias Split.Treatment + alias Split.Impression defstruct span_name: nil, telemetry_span_context: nil, start_time: nil, start_metadata: nil @@ -300,17 +300,10 @@ defmodule Split.Telemetry do @doc """ Emits a telemetry `impression` event when a Split treatment has been evaluated. """ - @spec send_impression(String.t(), String.t(), Treatment.t()) :: :ok - def send_impression(user_key, feature_name, %Treatment{} = treatment) do + @spec send_impression(Impression.t()) :: :ok + def send_impression(%Impression{} = impression) do :telemetry.execute([@app_name, :impression], %{}, %{ - impression: %Split.Impression{ - key: user_key, - feature: feature_name, - treatment: treatment.treatment, - label: treatment.label, - change_number: treatment.change_number, - timestamp: treatment.timestamp - } + impression: impression }) end diff --git a/lib/split/treatment.ex b/lib/split/treatment.ex deleted file mode 100644 index 6cea07b..0000000 --- a/lib/split/treatment.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule Split.Treatment do - defstruct treatment: "control", - label: nil, - config: nil, - change_number: nil, - timestamp: nil - - @type t :: %__MODULE__{ - treatment: String.t(), - label: String.t() | nil, - config: String.t() | nil, - change_number: integer() | nil, - timestamp: integer() | nil - } - - @spec build_from_daemon_response(map()) :: t - def build_from_daemon_response(treatment_payload) do - treatment = treatment_payload["t"] - config = treatment_payload["c"] - label = get_in(treatment_payload, ["l", "l"]) - change_number = get_in(treatment_payload, ["l", "c"]) - timestamp = get_in(treatment_payload, ["l", "m"]) - - %Split.Treatment{ - treatment: treatment, - label: label, - config: config, - change_number: change_number, - timestamp: timestamp - } - end -end diff --git a/lib/split/treatment_with_config.ex b/lib/split/treatment_with_config.ex new file mode 100644 index 0000000..fe4ee14 --- /dev/null +++ b/lib/split/treatment_with_config.ex @@ -0,0 +1,17 @@ +defmodule Split.TreatmentWithConfig do + @moduledoc """ + This module is a struct that represents a treatment with a configuration. + + ## Fields + * `:treatment` - The treatment string value + * `:config` - The treatment configuration string or nil if the treatment has no configuration + """ + + defstruct treatment: "control", + config: nil + + @type t :: %__MODULE__{ + treatment: String.t(), + config: String.t() | nil + } +end diff --git a/mix.exs b/mix.exs index 1184023..2caeccf 100644 --- a/mix.exs +++ b/mix.exs @@ -4,17 +4,27 @@ defmodule SplitThinElixir.MixProject do def project do [ app: :split, - version: "0.1.0", + version: "0.2.0", elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), runtime_tools: [:observer], package: package(), + docs: [ + filter_modules: fn mod, _meta -> + # Skip modules that are not part of the public API + mod in [ + Split, + Split.Supervisor, + Split.SplitView, + Split.TreatmentWithConfig + ] + end + ] ] end - # Package-specific metadata for Hex.pm defp package do [ @@ -25,7 +35,7 @@ defmodule SplitThinElixir.MixProject do "GitHub" => "https://github.com/splitio/elixir-thin-client", "Docs" => "https://hexdocs.pm/split_thin_sdk" }, - maintainers: ["Emiliano Sanchez", "Nicolas Zelaya", "split-fme-libraries@harness.io"], + maintainers: ["Emiliano Sanchez", "Nicolas Zelaya", "split-fme-libraries@harness.io"] ] end diff --git a/test/rpc/fallback_test.exs b/test/rpc/fallback_test.exs index b764328..8a23758 100644 --- a/test/rpc/fallback_test.exs +++ b/test/rpc/fallback_test.exs @@ -3,7 +3,7 @@ defmodule Split.RPC.FallbackTest do alias Split.RPC.Fallback alias Split.RPC.Message - alias Split.Treatment + alias Split.Impression doctest Split.RPC.Fallback end diff --git a/test/rpc/response_parser_test.exs b/test/rpc/response_parser_test.exs index d15f261..6f3c31d 100644 --- a/test/rpc/response_parser_test.exs +++ b/test/rpc/response_parser_test.exs @@ -5,7 +5,8 @@ defmodule Split.RPC.ResponseParserTest do alias Split.RPC.Fallback alias Split.RPC.ResponseParser alias Split.RPC.Message - alias Split.Treatment + alias Split.Impression + alias Split.SplitView import ExUnit.CaptureLog @@ -24,14 +25,16 @@ defmodule Split.RPC.ResponseParserTest do }} assert ResponseParser.parse_response(response, message) == - {:ok, - %Treatment{ - change_number: 123, - config: nil, - label: "test label", - timestamp: 1_723_742_604, - treatment: "on" - }} + %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name", + change_number: 123, + config: nil, + label: "test label", + timestamp: 1_723_742_604, + treatment: "on" + } end test "parses get_treatment_with_config RPC response" do @@ -52,14 +55,16 @@ defmodule Split.RPC.ResponseParserTest do }} assert ResponseParser.parse_response(response, message) == - {:ok, - %Treatment{ - change_number: 123, - config: "{\"foo\": \"bar\"}", - label: "test label", - timestamp: 1_723_742_604, - treatment: "on" - }} + %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name", + change_number: 123, + config: "{\"foo\": \"bar\"}", + label: "test label", + timestamp: 1_723_742_604, + treatment: "on" + } end test "parses get_treatments RPC response" do @@ -81,23 +86,28 @@ defmodule Split.RPC.ResponseParserTest do }} assert ResponseParser.parse_response(response, message) == - {:ok, - %{ - "feature_name1" => %Split.Treatment{ - treatment: "on", - label: "test label 1", - config: nil, - change_number: 123, - timestamp: 1_723_742_604 - }, - "feature_name2" => %Split.Treatment{ - treatment: "off", - label: "test label 2", - config: nil, - change_number: 456, - timestamp: 1_723_742_604 - } - }} + %{ + "feature_name1" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name1", + treatment: "on", + label: "test label 1", + config: nil, + change_number: 123, + timestamp: 1_723_742_604 + }, + "feature_name2" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name2", + treatment: "off", + label: "test label 2", + config: nil, + change_number: 456, + timestamp: 1_723_742_604 + } + } end test "parses get_treatments_with_config RPC response" do @@ -127,23 +137,228 @@ defmodule Split.RPC.ResponseParserTest do }} assert ResponseParser.parse_response(response, message) == - {:ok, - %{ - "feature_name1" => %Split.Treatment{ - treatment: "on", - label: "test label 1", - config: "{\"foo\": \"bar\"}", - change_number: 123, - timestamp: 1_723_742_604 - }, - "feature_name2" => %Split.Treatment{ - treatment: "off", - label: "test label 2", - config: "{\"baz\": \"qux\"}", - change_number: 456, - timestamp: 1_723_742_604 - } - }} + %{ + "feature_name1" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name1", + treatment: "on", + label: "test label 1", + config: "{\"foo\": \"bar\"}", + change_number: 123, + timestamp: 1_723_742_604 + }, + "feature_name2" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name2", + treatment: "off", + label: "test label 2", + config: "{\"baz\": \"qux\"}", + change_number: 456, + timestamp: 1_723_742_604 + } + } + end + + test "parses get_treatments_by_flag_set RPC response" do + message = %Message{ + o: @get_treatments_by_flag_set_opcode, + a: ["user_key", "bucketing_key", "flag_set_name"] + } + + response = + {:ok, + %{ + "s" => 1, + "p" => %{ + "r" => %{ + "feature_name1" => %{ + "t" => "on", + "l" => %{"l" => "test label 1", "c" => 123, "m" => 1_723_742_604} + }, + "feature_name2" => %{ + "t" => "off", + "l" => %{"l" => "test label 2", "c" => 456, "m" => 1_723_742_604} + } + } + } + }} + + assert ResponseParser.parse_response(response, message) == + %{ + "feature_name1" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name1", + treatment: "on", + label: "test label 1", + config: nil, + change_number: 123, + timestamp: 1_723_742_604 + }, + "feature_name2" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name2", + treatment: "off", + label: "test label 2", + config: nil, + change_number: 456, + timestamp: 1_723_742_604 + } + } + end + + test "parses get_treatments_with_config_by_flag_set RPC response" do + message = %Message{ + o: @get_treatments_with_config_by_flag_set_opcode, + a: ["user_key", "bucketing_key", "flag_set_name"] + } + + response = + {:ok, + %{ + "s" => 1, + "p" => %{ + "r" => %{ + "feature_name1" => %{ + "t" => "on", + "l" => %{"l" => "test label 1", "c" => 123, "m" => 1_723_742_604}, + "c" => "{\"foo\": \"bar\"}" + }, + "feature_name2" => %{ + "t" => "off", + "l" => %{"l" => "test label 2", "c" => 456, "m" => 1_723_742_604}, + "c" => "{\"baz\": \"qux\"}" + } + } + } + }} + + assert ResponseParser.parse_response(response, message) == + %{ + "feature_name1" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name1", + treatment: "on", + label: "test label 1", + config: "{\"foo\": \"bar\"}", + change_number: 123, + timestamp: 1_723_742_604 + }, + "feature_name2" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name2", + treatment: "off", + label: "test label 2", + config: "{\"baz\": \"qux\"}", + change_number: 456, + timestamp: 1_723_742_604 + } + } + end + + test "parses get_treatments_by_flag_sets RPC response" do + message = %Message{ + o: @get_treatments_by_flag_sets_opcode, + a: ["user_key", "bucketing_key", ["flag_set_name1", "flag_set_name2"]] + } + + response = + {:ok, + %{ + "s" => 1, + "p" => %{ + "r" => %{ + "feature_name1" => %{ + "t" => "on", + "l" => %{"l" => "test label 1", "c" => 123, "m" => 1_723_742_604} + }, + "feature_name2" => %{ + "t" => "off", + "l" => %{"l" => "test label 2", "c" => 456, "m" => 1_723_742_604} + } + } + } + }} + + assert ResponseParser.parse_response(response, message) == + %{ + "feature_name1" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name1", + treatment: "on", + label: "test label 1", + config: nil, + change_number: 123, + timestamp: 1_723_742_604 + }, + "feature_name2" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name2", + treatment: "off", + label: "test label 2", + config: nil, + change_number: 456, + timestamp: 1_723_742_604 + } + } + end + + test "parses get_treatments_with_config_by_flag_sets RPC response" do + message = %Message{ + o: @get_treatments_with_config_by_flag_sets_opcode, + a: ["user_key", "bucketing_key", ["flag_set_name1", "flag_set_name2"]] + } + + response = + {:ok, + %{ + "s" => 1, + "p" => %{ + "r" => %{ + "feature_name1" => %{ + "t" => "on", + "l" => %{"l" => "test label 1", "c" => 123, "m" => 1_723_742_604}, + "c" => "{\"foo\": \"bar\"}" + }, + "feature_name2" => %{ + "t" => "off", + "l" => %{"l" => "test label 2", "c" => 456, "m" => 1_723_742_604}, + "c" => "{\"baz\": \"qux\"}" + } + } + } + }} + + assert ResponseParser.parse_response(response, message) == + %{ + "feature_name1" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name1", + treatment: "on", + label: "test label 1", + config: "{\"foo\": \"bar\"}", + change_number: 123, + timestamp: 1_723_742_604 + }, + "feature_name2" => %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name2", + treatment: "off", + label: "test label 2", + config: "{\"baz\": \"qux\"}", + change_number: 456, + timestamp: 1_723_742_604 + } + } end test "parses split RPC call" do @@ -170,17 +385,17 @@ defmodule Split.RPC.ResponseParserTest do }} assert ResponseParser.parse_response(response, message) == - {:ok, - %Split{ - name: "feature_name", - traffic_type: "user", - killed: false, - treatments: ["treatment_a", "treatment_b", "treatment_c"], - change_number: 1_499_375_079_065, - configurations: %{}, - default_treatment: "treatment_a", - flag_sets: [] - }} + %SplitView{ + name: "feature_name", + traffic_type: "user", + killed: false, + treatments: ["treatment_a", "treatment_b", "treatment_c"], + change_number: 1_499_375_079_065, + configs: %{}, + default_treatment: "treatment_a", + sets: [], + impressions_disabled: false + } end test "parses splits RPC call" do @@ -224,30 +439,31 @@ defmodule Split.RPC.ResponseParserTest do }} # Order of splits is not guaranteed - assert ResponseParser.parse_response(response, message) |> sorted_by(& &1.name) == - {:ok, - [ - %Split{ - name: "feature_a", - traffic_type: "user", - killed: false, - treatments: ["treatment_a", "treatment_b", "treatment_c"], - change_number: 1_499_375_079_065, - configurations: %{}, - default_treatment: "treatment_a", - flag_sets: [] - }, - %Split{ - name: "feature_b", - traffic_type: "user", - killed: false, - treatments: ["on", "off"], - change_number: 1_499_375_079_066, - configurations: %{}, - default_treatment: "off", - flag_sets: [] - } - ]} + assert ResponseParser.parse_response(response, message) |> Enum.sort_by(& &1.name) == + [ + %SplitView{ + name: "feature_a", + traffic_type: "user", + killed: false, + treatments: ["treatment_a", "treatment_b", "treatment_c"], + change_number: 1_499_375_079_065, + configs: %{}, + default_treatment: "treatment_a", + sets: [], + impressions_disabled: false + }, + %SplitView{ + name: "feature_b", + traffic_type: "user", + killed: false, + treatments: ["on", "off"], + change_number: 1_499_375_079_066, + configs: %{}, + default_treatment: "off", + sets: [], + impressions_disabled: false + } + ] end test "parses split_names RPC call" do @@ -266,7 +482,7 @@ defmodule Split.RPC.ResponseParserTest do }} assert ResponseParser.parse_response(response, message) == - {:ok, %{split_names: ["feature_a", "feature_b"]}} + ["feature_a", "feature_b"] end test "parses successful track RPC call" do @@ -274,7 +490,7 @@ defmodule Split.RPC.ResponseParserTest do response = {:ok, %{"s" => 1, "p" => %{"s" => true}}} - assert ResponseParser.parse_response(response, message) == :ok + assert ResponseParser.parse_response(response, message) == true end test "parses failed track RPC call" do @@ -282,59 +498,53 @@ defmodule Split.RPC.ResponseParserTest do response = {:ok, %{"s" => 1, "p" => %{"s" => false}}} - assert ResponseParser.parse_response(response, message) == :error + assert ResponseParser.parse_response(response, message) == false end - test "handles splitd internal error" do - message = %Message{ - o: @get_treatments_with_config_opcode, - a: ["user_key", "bucketing_key", ["feature_name1", "feature_name2"]] - } - - response = {:ok, %{"s" => 0x10, "p" => %{"m" => "Some bad error"}}} - - assert capture_log(fn -> - assert ResponseParser.parse_response(response, message) == - {:error, :splitd_internal_error} - end) =~ "Error response received from Splitd" - end - - test "handles unknow/unparsable payload" do - message = %Message{ - o: @get_treatments_with_config_opcode, - a: ["user_key", "bucketing_key", ["feature_name1", "feature_name2"]] - } - - response = {:ok, "some bad payload"} - - assert capture_log(fn -> - assert ResponseParser.parse_response(response, message) == - {:error, :splitd_parse_error} - end) =~ "Unable to parse Splitd response" - end - - test "handles socket errors" do - message = %Message{ - o: @get_treatments_with_config_opcode, - a: ["user_key", "bucketing_key", ["feature_name1", "feature_name2"]] - } - - response = {:error, :enoent} - - assert capture_log(fn -> - assert ResponseParser.parse_response(response, message) == - {:error, :enoent} - end) =~ "Error while communicating with Splitd" - end + # test "handles splitd internal error" do + # message = %Message{ + # o: @get_treatments_with_config_opcode, + # a: ["user_key", "bucketing_key", ["feature_name1", "feature_name2"]] + # } + + # response = {:ok, %{"s" => 0x10, "p" => %{"m" => "Some bad error"}}} + + # assert capture_log(fn -> + # assert ResponseParser.parse_response(response, message) == + # {:error, :splitd_internal_error} + # end) =~ "Error response received from Splitd" + # end + + # test "handles unknow/unparsable payload" do + # message = %Message{ + # o: @get_treatments_with_config_opcode, + # a: ["user_key", "bucketing_key", ["feature_name1", "feature_name2"]] + # } + + # response = {:ok, "some bad payload"} + + # assert capture_log(fn -> + # assert ResponseParser.parse_response(response, message) == + # {:error, :splitd_parse_error} + # end) =~ "Unable to parse Splitd response" + # end + + # test "handles socket errors" do + # message = %Message{ + # o: @get_treatments_with_config_opcode, + # a: ["user_key", "bucketing_key", ["feature_name1", "feature_name2"]] + # } + + # response = {:error, :enoent} + + # assert capture_log(fn -> + # assert ResponseParser.parse_response(response, message) == + # {:error, :enoent} + # end) =~ "Error while communicating with Splitd" + # end end - describe "parse_response/2 with fallback enabled" do - setup do - old_value = :persistent_term.get(:splitd_fallback_enabled, false) - :persistent_term.put(:splitd_fallback_enabled, true) - on_exit(fn -> :persistent_term.put(:splitd_fallback_enabled, old_value) end) - end - + describe "parse_response/2 with fallback" do test "returns fallback for the sent message on invalid splitd response" do message = %Message{o: @split_opcode, a: []} @@ -342,7 +552,7 @@ defmodule Split.RPC.ResponseParserTest do assert capture_log(fn -> assert ResponseParser.parse_response(response, message) == - {:ok, nil} + nil end) =~ "Unable to parse Splitd response" end @@ -356,14 +566,13 @@ defmodule Split.RPC.ResponseParserTest do assert capture_log(fn -> assert ResponseParser.parse_response(response, message) == - {:ok, - %Split.Treatment{ - change_number: nil, - config: nil, - label: "fallback treatment", - timestamp: nil, - treatment: "control" - }} + %Impression{ + change_number: nil, + config: nil, + label: "exception", + timestamp: nil, + treatment: "control" + } end) =~ "Error response received from Splitd" end @@ -377,16 +586,15 @@ defmodule Split.RPC.ResponseParserTest do assert capture_log(fn -> assert ResponseParser.parse_response(response, message) == - {:ok, - %{ - "feature_name1" => %Split.Treatment{ - treatment: "control", - label: "fallback treatment", - config: nil, - change_number: nil, - timestamp: nil - } - }} + %{ + "feature_name1" => %Impression{ + treatment: "control", + label: "exception", + config: nil, + change_number: nil, + timestamp: nil + } + } end) =~ "Error while communicating with Splitd" end @@ -418,11 +626,4 @@ defmodule Split.RPC.ResponseParserTest do }} end end - - defp sorted_by(response, fun) do - response - |> elem(1) - |> Enum.sort_by(fun) - |> then(&{:ok, &1}) - end end diff --git a/test/sockets/conn_test.exs b/test/sockets/conn_test.exs index fbb7a1a..9546132 100644 --- a/test/sockets/conn_test.exs +++ b/test/sockets/conn_test.exs @@ -87,7 +87,7 @@ defmodule Split.Sockets.ConnTest do {:ok, conn} = Conn.new(socket_path) |> Conn.connect() - message = Message.get_treatment(user_key: "user-id", feature_name: "feature") + message = Message.get_treatment(key: "user-id", feature_name: "feature") {:ok, _conn, response} = Conn.send_message(conn, message) @@ -108,7 +108,7 @@ defmodule Split.Sockets.ConnTest do {:ok, conn} = Conn.new(socket_path) |> Conn.connect() - message = Message.get_treatment(user_key: "user-id", feature_name: "feature") + message = Message.get_treatment(key: "user-id", feature_name: "feature") # Stop the mocked splitd socket to receive connection errors :ok = stop_supervised(splitd_name) @@ -129,7 +129,7 @@ defmodule Split.Sockets.ConnTest do {:ok, conn} = Conn.new(socket_path) |> Conn.connect() - message = Message.get_treatment(user_key: "user-id", feature_name: "feature") + message = Message.get_treatment(key: "user-id", feature_name: "feature") assert {:ok, _conn, response} = Conn.send_message(conn, message) diff --git a/test/sockets/pool_test.exs b/test/sockets/pool_test.exs index f5182b3..66821bd 100644 --- a/test/sockets/pool_test.exs +++ b/test/sockets/pool_test.exs @@ -8,7 +8,7 @@ defmodule Split.Sockets.PoolTest do import ExUnit.CaptureLog setup_all context do - test_id = :erlang.phash2(context.case) + test_id = :erlang.phash2(context.module) socket_path = "/tmp/test-splitd-#{test_id}.sock" start_supervised!( diff --git a/test/split/impression_test.exs b/test/split/impression_test.exs new file mode 100644 index 0000000..6ddb3bd --- /dev/null +++ b/test/split/impression_test.exs @@ -0,0 +1,63 @@ +defmodule Split.ImpressionTest do + use ExUnit.Case + + alias Split.Impression + + describe "build_from_daemon_response/1" do + test "builds an impression struct from a daemon response" do + treatment_payload = %{ + "t" => "treatment", + "c" => "{\"field\": \"value\"}", + "l" => %{ + "l" => "label", + "c" => 1, + "m" => 2 + } + } + + expected = %Impression{ + key: "user_key", + bucketing_key: "bucketing_key", + feature: "feature_name", + treatment: "treatment", + label: "label", + config: "{\"field\": \"value\"}", + change_number: 1, + timestamp: 2 + } + + assert expected == + Impression.build_from_daemon_response( + treatment_payload, + "user_key", + "bucketing_key", + "feature_name" + ) + end + + test "builds an impression struct with nil values" do + treatment_payload = %{ + "t" => "treatment" + } + + expected = %Impression{ + key: "user_key", + bucketing_key: nil, + feature: "feature_name", + treatment: "treatment", + label: nil, + config: nil, + change_number: nil, + timestamp: nil + } + + assert expected == + Impression.build_from_daemon_response( + treatment_payload, + "user_key", + nil, + "feature_name" + ) + end + end +end diff --git a/test/split/treatment_test.exs b/test/split/treatment_test.exs deleted file mode 100644 index da4c369..0000000 --- a/test/split/treatment_test.exs +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Split.TreatmentTest do - use ExUnit.Case - - alias Split.Treatment - - describe "build_from_daemon_response/1" do - test "builds a treatment struct from a daemon response" do - treatment_payload = %{ - "t" => "treatment", - "c" => nil, - "l" => %{ - "l" => "label", - "c" => 1, - "m" => 2 - } - } - - expected = %Treatment{ - treatment: "treatment", - label: "label", - config: nil, - change_number: 1, - timestamp: 2 - } - - assert expected == Treatment.build_from_daemon_response(treatment_payload) - end - - test "builds a treatment struct with nil values" do - treatment_payload = %{ - "t" => "treatment" - } - - expected = %Treatment{ - treatment: "treatment", - label: nil, - config: nil, - change_number: nil, - timestamp: nil - } - - assert expected == Treatment.build_from_daemon_response(treatment_payload) - end - end -end diff --git a/test/split_test.exs b/test/split_test.exs index 4d6ebc9..4c45d67 100644 --- a/test/split_test.exs +++ b/test/split_test.exs @@ -2,10 +2,11 @@ defmodule SplitThinElixirTest do use ExUnit.Case alias Split.Impression - alias Split.Treatment + alias Split.TreatmentWithConfig + alias Split.SplitView setup_all context do - test_id = :erlang.phash2(context.case) + test_id = :erlang.phash2(context.module) socket_path = "/tmp/test-splitd-#{test_id}.sock" start_supervised!( @@ -19,10 +20,16 @@ defmodule SplitThinElixirTest do :ok end - describe "get_treatment/2" do + describe "get_treatment/3" do test "returns expected struct" do - assert {:ok, %{treatment: "on"}} = - Split.get_treatment("user-id-" <> to_string(Enum.random(1..100_000)), "ethan_test") + assert "on" = + Split.get_treatment( + "user-id-" <> to_string(Enum.random(1..100_000)), + "ethan_test", + %{ + :some_attribute => "some_value" + } + ) end test "emits telemetry event for impression listening" do @@ -34,9 +41,9 @@ defmodule SplitThinElixirTest do end end - describe "get_treatment_with_config/2" do + describe "get_treatment_with_config/3" do test "returns expected struct" do - assert {:ok, %Treatment{treatment: "on", config: %{"foo" => "bar"}}} = + assert %TreatmentWithConfig{treatment: "on", config: %{"foo" => "bar"}} = Split.get_treatment_with_config( "user-id-" <> to_string(Enum.random(1..100_000)), "ethan_test" @@ -55,9 +62,9 @@ defmodule SplitThinElixirTest do end end - describe "get_treatments/2" do + describe "get_treatments/3" do test "returns expected map with structs" do - assert {:ok, %{"ethan_test" => %Treatment{treatment: "on"}}} = + assert %{"ethan_test" => "on"} = Split.get_treatments("user-id-" <> to_string(Enum.random(1..100_000)), [ "ethan_test" ]) @@ -72,9 +79,9 @@ defmodule SplitThinElixirTest do end end - describe "get_treatments_with_config/2" do + describe "get_treatments_with_config/3" do test "returns expected struct" do - assert {:ok, %{"ethan_test" => %Treatment{treatment: "on", config: %{"foo" => "bar"}}}} = + assert %{"ethan_test" => %TreatmentWithConfig{treatment: "on", config: %{"foo" => "bar"}}} = Split.get_treatments_with_config( "user-id-" <> to_string(Enum.random(1..100_000)), [ @@ -94,22 +101,117 @@ defmodule SplitThinElixirTest do end end - test "track/3" do - assert :ok = - Split.track("user-id-" <> to_string(Enum.random(1..100_000)), "account", "purchase") + describe "get_treatments_by_flag_set/3" do + test "returns expected map with structs" do + assert %{"emi_test" => "on"} = + Split.get_treatments_by_flag_set( + "user-id-" <> to_string(Enum.random(1..100_000)), + "flag_set_name" + ) + end + + test "emits telemetry event for impression listening" do + ref = :telemetry_test.attach_event_handlers(self(), [[:split, :impression]]) + + Split.get_treatments_by_flag_set( + "user-id-" <> to_string(Enum.random(1..100_000)), + "flag_set_name" + ) + + assert_received {[:split, :impression], ^ref, _, %{impression: %Impression{}}} + end + end + + describe "get_treatments_with_config_by_flag_set/3" do + test "returns expected struct" do + assert %{"emi_test" => %TreatmentWithConfig{treatment: "on", config: %{"foo" => "bar"}}} = + Split.get_treatments_with_config_by_flag_set( + "user-id-" <> to_string(Enum.random(1..100_000)), + "flag_set_name" + ) + end + + test "emits telemetry event for impression listening" do + ref = :telemetry_test.attach_event_handlers(self(), [[:split, :impression]]) + + Split.get_treatments_with_config_by_flag_set( + "user-id-" <> to_string(Enum.random(1..100_000)), + "flag_set_name" + ) + + assert_received {[:split, :impression], ^ref, _, %{impression: %Impression{}}} + end + end + + describe "get_treatments_by_flag_sets/3" do + test "returns expected map with structs" do + assert %{"emi_test" => "on"} = + Split.get_treatments_by_flag_sets( + "user-id-" <> to_string(Enum.random(1..100_000)), + [ + "flag_set_name" + ] + ) + end + + test "emits telemetry event for impression listening" do + ref = :telemetry_test.attach_event_handlers(self(), [[:split, :impression]]) + + Split.get_treatments_by_flag_sets("user-id-" <> to_string(Enum.random(1..100_000)), [ + "flag_set_name" + ]) + + assert_received {[:split, :impression], ^ref, _, %{impression: %Impression{}}} + end + end + + describe "get_treatments_with_config_by_flag_sets/3" do + test "returns expected struct" do + assert %{"emi_test" => %TreatmentWithConfig{treatment: "on", config: %{"foo" => "bar"}}} = + Split.get_treatments_with_config_by_flag_sets( + "user-id-" <> to_string(Enum.random(1..100_000)), + [ + "flag_set_name" + ] + ) + end + + test "emits telemetry event for impression listening" do + ref = :telemetry_test.attach_event_handlers(self(), [[:split, :impression]]) + + Split.get_treatments_with_config_by_flag_sets( + "user-id-" <> to_string(Enum.random(1..100_000)), + [ + "flag_set_name" + ] + ) + + assert_received {[:split, :impression], ^ref, _, %{impression: %Impression{}}} + end + end + + test "track/5" do + assert true = + Split.track( + "user-id-" <> to_string(Enum.random(1..100_000)), + "account", + "purchase", + 100, + %{"currency" => "USD"} + ) end test "split_names/0" do - assert {:ok, %{split_names: ["ethan_test"]}} == Split.split_names() + assert ["ethan_test"] == Split.split_names() end test "split/1" do - assert {:ok, %Split{name: "test-split"}} = + assert %SplitView{name: "test-split"} = Split.split("test-split") end test "splits/0" do - assert {:ok, [%Split{name: "test-split"}]} = Split.splits() + assert [%SplitView{name: "test-split"}] = Split.splits() end describe "telemetry" do @@ -120,7 +222,7 @@ defmodule SplitThinElixirTest do [:split, :rpc, :stop] ]) - {:ok, split} = Split.split("test-split") + split = Split.split("test-split") assert_received {[:split, :rpc, :start], ^ref, _, %{rpc_call: :split}} diff --git a/test/support/mock_splitd_server.ex b/test/support/mock_splitd_server.ex index f3668a2..91190c0 100644 --- a/test/support/mock_splitd_server.ex +++ b/test/support/mock_splitd_server.ex @@ -105,6 +105,18 @@ defmodule Split.Test.MockSplitdServer do %{"s" => 1, "p" => %{"r" => [%{"t" => "on", "c" => %{"foo" => "bar"}}]}} end + defp build_response(21), do: %{"s" => 1, "p" => %{"r" => %{"emi_test" => %{"t" => "on"}}}} + + defp build_response(22) do + %{"s" => 1, "p" => %{"r" => %{"emi_test" => %{"t" => "on", "c" => %{"foo" => "bar"}}}}} + end + + defp build_response(23), do: %{"s" => 1, "p" => %{"r" => %{"emi_test" => %{"t" => "on"}}}} + + defp build_response(24) do + %{"s" => 1, "p" => %{"r" => %{"emi_test" => %{"t" => "on", "c" => %{"foo" => "bar"}}}}} + end + defp build_response(128), do: %{"s" => 1, "p" => %{"s" => true}} defp build_response(160), do: %{"s" => 1, "p" => %{"n" => ["ethan_test"]}}