├── .codeclimate.yml ├── .csslintrc ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── split.rb └── split │ ├── algorithms.rb │ ├── algorithms │ ├── block_randomization.rb │ ├── weighted_sample.rb │ └── whiplash.rb │ ├── alternative.rb │ ├── cache.rb │ ├── combined_experiments_helper.rb │ ├── configuration.rb │ ├── dashboard.rb │ ├── dashboard │ ├── helpers.rb │ ├── pagination_helpers.rb │ ├── paginator.rb │ ├── public │ │ ├── dashboard-filtering.js │ │ ├── dashboard.js │ │ ├── jquery-1.11.1.min.js │ │ ├── reset.css │ │ └── style.css │ └── views │ │ ├── _controls.erb │ │ ├── _experiment.erb │ │ ├── _experiment_with_goal_header.erb │ │ ├── index.erb │ │ └── layout.erb │ ├── encapsulated_helper.rb │ ├── engine.rb │ ├── exceptions.rb │ ├── experiment.rb │ ├── experiment_catalog.rb │ ├── extensions │ └── string.rb │ ├── goals_collection.rb │ ├── helper.rb │ ├── metric.rb │ ├── persistence.rb │ ├── persistence │ ├── cookie_adapter.rb │ ├── dual_adapter.rb │ ├── redis_adapter.rb │ └── session_adapter.rb │ ├── redis_interface.rb │ ├── trial.rb │ ├── user.rb │ ├── version.rb │ └── zscore.rb ├── spec ├── algorithms │ ├── block_randomization_spec.rb │ ├── weighted_sample_spec.rb │ └── whiplash_spec.rb ├── alternative_spec.rb ├── cache_spec.rb ├── combined_experiments_helper_spec.rb ├── configuration_spec.rb ├── dashboard │ ├── pagination_helpers_spec.rb │ └── paginator_spec.rb ├── dashboard_helpers_spec.rb ├── dashboard_spec.rb ├── encapsulated_helper_spec.rb ├── experiment_catalog_spec.rb ├── experiment_spec.rb ├── goals_collection_spec.rb ├── helper_spec.rb ├── metric_spec.rb ├── persistence │ ├── cookie_adapter_spec.rb │ ├── dual_adapter_spec.rb │ ├── redis_adapter_spec.rb │ └── session_adapter_spec.rb ├── persistence_spec.rb ├── redis_interface_spec.rb ├── spec_helper.rb ├── split_spec.rb ├── support │ └── cookies_mock.rb ├── trial_spec.rb └── user_spec.rb └── split.gemspec /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | csslint: 4 | enabled: true 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - ruby 10 | - javascript 11 | - python 12 | - php 13 | eslint: 14 | enabled: true 15 | fixme: 16 | enabled: true 17 | rubocop: 18 | enabled: true 19 | ratings: 20 | paths: 21 | - "**.css" 22 | - "**.inc" 23 | - "**.js" 24 | - "**.jsx" 25 | - "**.module" 26 | - "**.php" 27 | - "**.py" 28 | - "**.rb" 29 | exclude_paths: 30 | - spec/ 31 | -------------------------------------------------------------------------------- /.csslintrc: -------------------------------------------------------------------------------- 1 | --exclude-exts=.min.css 2 | --ignore=adjoining-classes,box-model,ids,order-alphabetical,unqualified-attributes 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | ecmaFeatures: 2 | modules: true 3 | jsx: true 4 | 5 | env: 6 | amd: true 7 | browser: true 8 | es6: true 9 | jquery: true 10 | node: true 11 | 12 | # https://eslint.org/docs/rules/ 13 | rules: 14 | # Possible Errors 15 | comma-dangle: [2, never] 16 | no-cond-assign: 2 17 | no-console: 0 18 | no-constant-condition: 2 19 | no-control-regex: 2 20 | no-debugger: 2 21 | no-dupe-args: 2 22 | no-dupe-keys: 2 23 | no-duplicate-case: 2 24 | no-empty: 2 25 | no-empty-character-class: 2 26 | no-ex-assign: 2 27 | no-extra-boolean-cast: 2 28 | no-extra-parens: 0 29 | no-extra-semi: 2 30 | no-func-assign: 2 31 | no-inner-declarations: [2, functions] 32 | no-invalid-regexp: 2 33 | no-irregular-whitespace: 2 34 | no-negated-in-lhs: 2 35 | no-obj-calls: 2 36 | no-regex-spaces: 2 37 | no-sparse-arrays: 2 38 | no-unexpected-multiline: 2 39 | no-unreachable: 2 40 | use-isnan: 2 41 | valid-jsdoc: 0 42 | valid-typeof: 2 43 | 44 | # Best Practices 45 | accessor-pairs: 2 46 | block-scoped-var: 0 47 | complexity: [2, 6] 48 | consistent-return: 0 49 | curly: 0 50 | default-case: 0 51 | dot-location: 0 52 | dot-notation: 0 53 | eqeqeq: 2 54 | guard-for-in: 2 55 | no-alert: 2 56 | no-caller: 2 57 | no-case-declarations: 2 58 | no-div-regex: 2 59 | no-else-return: 0 60 | no-empty-label: 2 61 | no-empty-pattern: 2 62 | no-eq-null: 2 63 | no-eval: 2 64 | no-extend-native: 2 65 | no-extra-bind: 2 66 | no-fallthrough: 2 67 | no-floating-decimal: 0 68 | no-implicit-coercion: 0 69 | no-implied-eval: 2 70 | no-invalid-this: 0 71 | no-iterator: 2 72 | no-labels: 0 73 | no-lone-blocks: 2 74 | no-loop-func: 2 75 | no-magic-number: 0 76 | no-multi-spaces: 0 77 | no-multi-str: 0 78 | no-native-reassign: 2 79 | no-new-func: 2 80 | no-new-wrappers: 2 81 | no-new: 2 82 | no-octal-escape: 2 83 | no-octal: 2 84 | no-proto: 2 85 | no-redeclare: 2 86 | no-return-assign: 2 87 | no-script-url: 2 88 | no-self-compare: 2 89 | no-sequences: 0 90 | no-throw-literal: 0 91 | no-unused-expressions: 2 92 | no-useless-call: 2 93 | no-useless-concat: 2 94 | no-void: 2 95 | no-warning-comments: 0 96 | no-with: 2 97 | radix: 2 98 | vars-on-top: 0 99 | wrap-iife: 2 100 | yoda: 0 101 | 102 | # Strict 103 | strict: 0 104 | 105 | # Variables 106 | init-declarations: 0 107 | no-catch-shadow: 2 108 | no-delete-var: 2 109 | no-label-var: 2 110 | no-shadow-restricted-names: 2 111 | no-shadow: 0 112 | no-undef-init: 2 113 | no-undef: 0 114 | no-undefined: 0 115 | no-unused-vars: 0 116 | no-use-before-define: 0 117 | 118 | # Node.js and CommonJS 119 | callback-return: 2 120 | global-require: 2 121 | handle-callback-err: 2 122 | no-mixed-requires: 0 123 | no-new-require: 0 124 | no-path-concat: 2 125 | no-process-exit: 2 126 | no-restricted-modules: 0 127 | no-sync: 0 128 | 129 | # Stylistic Issues 130 | array-bracket-spacing: 0 131 | block-spacing: 0 132 | brace-style: 0 133 | camelcase: 0 134 | comma-spacing: 0 135 | comma-style: 0 136 | computed-property-spacing: 0 137 | consistent-this: 0 138 | eol-last: 0 139 | func-names: 0 140 | func-style: 0 141 | id-length: 0 142 | id-match: 0 143 | indent: 0 144 | jsx-quotes: 0 145 | key-spacing: 0 146 | linebreak-style: 0 147 | lines-around-comment: 0 148 | max-depth: 0 149 | max-len: 0 150 | max-nested-callbacks: 0 151 | max-params: 0 152 | max-statements: [2, 30] 153 | new-cap: 0 154 | new-parens: 0 155 | newline-after-var: 0 156 | no-array-constructor: 0 157 | no-bitwise: 0 158 | no-continue: 0 159 | no-inline-comments: 0 160 | no-lonely-if: 0 161 | no-mixed-spaces-and-tabs: 0 162 | no-multiple-empty-lines: 0 163 | no-negated-condition: 0 164 | no-nested-ternary: 0 165 | no-new-object: 0 166 | no-plusplus: 0 167 | no-restricted-syntax: 0 168 | no-spaced-func: 0 169 | no-ternary: 0 170 | no-trailing-spaces: 0 171 | no-underscore-dangle: 0 172 | no-unneeded-ternary: 0 173 | object-curly-spacing: 0 174 | one-var: 0 175 | operator-assignment: 0 176 | operator-linebreak: 0 177 | padded-blocks: 0 178 | quote-props: 0 179 | quotes: 0 180 | require-jsdoc: 0 181 | semi-spacing: 0 182 | semi: 0 183 | sort-vars: 0 184 | space-after-keywords: 0 185 | space-before-blocks: 0 186 | space-before-function-paren: 0 187 | space-before-keywords: 0 188 | space-in-parens: 0 189 | space-infix-ops: 0 190 | space-return-throw-case: 0 191 | space-unary-ops: 0 192 | spaced-comment: 0 193 | wrap-regex: 0 194 | 195 | # ECMAScript 6 196 | arrow-body-style: 0 197 | arrow-parens: 0 198 | arrow-spacing: 0 199 | constructor-super: 0 200 | generator-star-spacing: 0 201 | no-arrow-condition: 0 202 | no-class-assign: 0 203 | no-const-assign: 0 204 | no-dupe-class-members: 0 205 | no-this-before-super: 0 206 | no-var: 0 207 | object-shorthand: 0 208 | prefer-arrow-callback: 0 209 | prefer-const: 0 210 | prefer-reflect: 0 211 | prefer-spread: 0 212 | prefer-template: 0 213 | require-yield: 0 214 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: split 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: andrehjr 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: split 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | ruby: ["3.4", "3.3", "3.2", "3.1", "3.0", "2.7"] 10 | rails: ["8.0", "7.2", "7.1", "6.1"] 11 | exclude: 12 | - rails: "6.1" 13 | ruby: "3.4" 14 | 15 | - rails: "7.2" 16 | ruby: "2.7" 17 | - rails: "7.2" 18 | ruby: "3.0" 19 | 20 | - rails: "8.0" 21 | ruby: "2.7" 22 | - rails: "8.0" 23 | ruby: "3.0" 24 | - rails: "8.0" 25 | ruby: "3.1" 26 | runs-on: ubuntu-latest 27 | 28 | services: 29 | redis: 30 | image: redis 31 | ports: ["6379:6379"] 32 | options: >- 33 | --health-cmd "redis-cli ping" 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | 38 | env: 39 | RAILS_VERSION: ${{ matrix.rails }} 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{ matrix.ruby }} 46 | 47 | - name: Install dependencies 48 | run: | 49 | bundle install --jobs 4 --retry 3 50 | 51 | - name: Display Ruby version 52 | run: ruby -v 53 | 54 | - name: Test 55 | run: bundle exec rspec 56 | env: 57 | REDIS_URL: redis:6379 58 | 59 | - name: Rubocop 60 | run: bundle exec rubocop 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | *.rbc 6 | .idea 7 | coverage 8 | issues.rtf 9 | dump.rdb 10 | .gitignore 11 | gemfiles/*.gemfile.lock 12 | .DS_Store -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --profile -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.5 3 | DisabledByDefault: true 4 | SuggestExtensions: false 5 | Exclude: 6 | - 'gemfiles/**/*' 7 | 8 | Style/AndOr: 9 | Enabled: true 10 | 11 | Layout/CaseIndentation: 12 | Enabled: true 13 | 14 | Layout/ClosingHeredocIndentation: 15 | Enabled: true 16 | 17 | Layout/CommentIndentation: 18 | Enabled: true 19 | 20 | Layout/ElseAlignment: 21 | Enabled: true 22 | 23 | Layout/EndAlignment: 24 | Enabled: true 25 | EnforcedStyleAlignWith: variable 26 | AutoCorrect: true 27 | 28 | Layout/EmptyLineAfterMagicComment: 29 | Enabled: true 30 | 31 | Layout/EmptyLinesAroundAccessModifier: 32 | Enabled: true 33 | EnforcedStyle: only_before 34 | 35 | Layout/EmptyLinesAroundBlockBody: 36 | Enabled: true 37 | 38 | Layout/EmptyLinesAroundClassBody: 39 | Enabled: true 40 | 41 | Layout/EmptyLinesAroundMethodBody: 42 | Enabled: true 43 | 44 | Layout/EmptyLinesAroundModuleBody: 45 | Enabled: true 46 | 47 | Style/HashSyntax: 48 | Enabled: true 49 | 50 | Layout/FirstArgumentIndentation: 51 | Enabled: true 52 | 53 | Layout/IndentationConsistency: 54 | Enabled: true 55 | EnforcedStyle: indented_internal_methods 56 | 57 | Layout/IndentationWidth: 58 | Enabled: true 59 | 60 | Layout/LeadingCommentSpace: 61 | Enabled: true 62 | 63 | Layout/SpaceAfterColon: 64 | Enabled: true 65 | 66 | Layout/SpaceAfterComma: 67 | Enabled: true 68 | 69 | Layout/SpaceAfterSemicolon: 70 | Enabled: true 71 | 72 | Layout/SpaceAroundEqualsInParameterDefault: 73 | Enabled: true 74 | 75 | Layout/SpaceAroundKeyword: 76 | Enabled: true 77 | 78 | Layout/SpaceBeforeComma: 79 | Enabled: true 80 | 81 | Layout/SpaceBeforeComment: 82 | Enabled: true 83 | 84 | Layout/SpaceBeforeFirstArg: 85 | Enabled: true 86 | 87 | Style/DefWithParentheses: 88 | Enabled: true 89 | 90 | Style/MethodDefParentheses: 91 | Enabled: true 92 | 93 | Style/FrozenStringLiteralComment: 94 | Enabled: true 95 | EnforcedStyle: always 96 | 97 | Style/RedundantFreeze: 98 | Enabled: true 99 | 100 | Layout/SpaceBeforeBlockBraces: 101 | Enabled: true 102 | 103 | Layout/SpaceInsideBlockBraces: 104 | Enabled: true 105 | EnforcedStyleForEmptyBraces: space 106 | 107 | Layout/SpaceInsideHashLiteralBraces: 108 | Enabled: true 109 | 110 | Layout/SpaceInsideParens: 111 | Enabled: true 112 | 113 | Style/StringLiterals: 114 | Enabled: true 115 | EnforcedStyle: double_quotes 116 | 117 | Layout/IndentationStyle: 118 | Enabled: true 119 | 120 | Layout/TrailingEmptyLines: 121 | Enabled: true 122 | 123 | Layout/TrailingWhitespace: 124 | Enabled: true 125 | 126 | Style/RedundantPercentQ: 127 | Enabled: true 128 | 129 | Lint/AmbiguousOperator: 130 | Enabled: true 131 | 132 | Lint/AmbiguousRegexpLiteral: 133 | Enabled: true 134 | 135 | Lint/ErbNewArguments: 136 | Enabled: true 137 | 138 | Lint/RequireParentheses: 139 | Enabled: true 140 | 141 | Lint/ShadowingOuterLocalVariable: 142 | Enabled: true 143 | 144 | Lint/RedundantStringCoercion: 145 | Enabled: true 146 | 147 | Lint/UriEscapeUnescape: 148 | Enabled: true 149 | 150 | Lint/UselessAssignment: 151 | Enabled: true 152 | 153 | Lint/DeprecatedClassMethods: 154 | Enabled: true 155 | 156 | Style/ParenthesesAroundCondition: 157 | Enabled: true 158 | 159 | Style/HashTransformKeys: 160 | Enabled: true 161 | 162 | Style/HashTransformValues: 163 | Enabled: true 164 | 165 | Style/RedundantBegin: 166 | Enabled: true 167 | 168 | Style/RedundantReturn: 169 | Enabled: true 170 | AllowMultipleReturnValues: true 171 | 172 | Style/Semicolon: 173 | Enabled: true 174 | AllowAsExpressionSeparator: true 175 | 176 | Style/ColonMethodCall: 177 | Enabled: true 178 | 179 | Style/TrivialAccessors: 180 | Enabled: true 181 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at andrewnez@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Split 2 | 3 | Want to contribute to Split? That's great! Here are a couple of guidelines that will help you contribute. Before we get started: Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md) to ensure that this project is a welcoming place for **everyone** to contribute to. By participating in this project you agree to abide by its terms. 4 | 5 | #### Overview 6 | 7 | * [Contribution workflow](#contribution-workflow) 8 | * [Setup instructions](#setup-instructions) 9 | * [Reporting a bug](#reporting-a-bug) 10 | * [Contributing to an existing issue](#contributing-to-an-existing-issue) 11 | * [Our labels](#our-labels) 12 | * [Additional info](#additional-info) 13 | 14 | ## Contribution workflow 15 | 16 | * Fork the project. 17 | * Make your feature addition or bug fix. 18 | * Add tests for it. This is important so I don't break it in a 19 | future version unintentionally. 20 | * Add documentation if necessary. 21 | * Commit. Do not mess with the Rakefile, version, or history. 22 | (If you want to have your own version, that is fine. But bump version in a commit by itself I can ignore when I pull.) 23 | * Send a pull request. Bonus points for topic branches. 24 | * Discussion at the [Google Group](https://groups.google.com/d/forum/split-ruby) 25 | 26 | ## Setup instructions 27 | 28 | You can find in-depth instructions to install in our [README](https://github.com/splitrb/split/blob/main/README.md). 29 | 30 | *Note*: Split requires Ruby 1.9.2 or higher. 31 | 32 | ## Reporting a bug 33 | 34 | So you've found a bug, and want to help us fix it? Before filing a bug report, please double-check the bug hasn't already been reported. You can do so [on our issue tracker](https://github.com/splitrb/split/issues?q=is%3Aissue+is%3Aopen+label%3Abug). If something hasn't been raised, you can go ahead and create a new issue with the following information: 35 | 36 | * When did the error happen? 37 | * How can the error be reproduced? 38 | * If possible, please also provide an error message or a screenshot to illustrate the problem. 39 | 40 | If you want to be really thorough, there is a great overview on Stack Overflow of [what you should consider when reporting a bug](http://stackoverflow.com/questions/240323/how-to-report-bugs-the-smart-way). 41 | 42 | It goes without saying that you're welcome to help investigate further and/or find a fix for the bug. If you want to do so, just mention it in your bug report and offer your help! 43 | 44 | ## Contributing to an existing issue 45 | 46 | ### Finding an issue to work on 47 | 48 | We've got a few open issues and are always glad to get help on that front. You can view the list of issues [here](https://github.com/splitrb/split/issues). Most of the issues are labelled, so you can use the labels to get an idea of which issue could be a good fit for you. (Here's [a good article](https://medium.freecodecamp.com/finding-your-first-open-source-project-or-bug-to-work-on-1712f651e5ba) on how to find your first bug to fix). 49 | 50 | Before getting to work, take a look at the issue and at the conversation around it. Has someone already offered to work on the issue? Has someone been assigned to the issue? If so, you might want to check with them to see whether they're still actively working on it. 51 | 52 | If the issue is a few months old, it might be a good idea to write a short comment to double-check that the issue or feature is still a valid one to jump on. 53 | 54 | Feel free to ask for more detail on what is expected: are there any more details or specifications you need to know? 55 | 56 | And if at any point you get stuck: don't hesitate to ask for help. 57 | 58 | ### Making your contribution 59 | 60 | We've outlined the contribution workflow [here](#contribution-workflow). If you're a first-timer, don't worry! GitHub has a ton of guides to help you through your first pull request: You can find out more about pull requests [here](https://help.github.com/articles/about-pull-requests/) and about creating a pull request [here](https://help.github.com/articles/creating-a-pull-request/). 61 | 62 | Especially if you're a newcomer to Open Source and you've found some little bumps along the way while contributing, we recommend you write about them. [Here](https://medium.freecodecamp.com/new-contributors-to-open-source-please-blog-more-920af14cffd)'s a great article about why writing about your experience is important; this will encourage other beginners to try their luck at Open Source, too! 63 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "rubocop", require: false 8 | gem "codeclimate-test-reporter" 9 | gem "concurrent-ruby", "< 1.3.5" 10 | 11 | gem "rails", "~> #{ENV.fetch('RAILS_VERSION', '8.0')}" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Andrew Nesbitt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | 4 | require "bundler/gem_tasks" 5 | require "rspec/core/rake_task" 6 | 7 | RSpec::Core::RakeTask.new("spec") 8 | 9 | task default: :spec 10 | -------------------------------------------------------------------------------- /lib/split.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "redis" 4 | 5 | require "split/algorithms" 6 | require "split/algorithms/block_randomization" 7 | require "split/algorithms/weighted_sample" 8 | require "split/algorithms/whiplash" 9 | require "split/alternative" 10 | require "split/cache" 11 | require "split/configuration" 12 | require "split/encapsulated_helper" 13 | require "split/exceptions" 14 | require "split/experiment" 15 | require "split/experiment_catalog" 16 | require "split/extensions/string" 17 | require "split/goals_collection" 18 | require "split/helper" 19 | require "split/combined_experiments_helper" 20 | require "split/metric" 21 | require "split/persistence" 22 | require "split/redis_interface" 23 | require "split/trial" 24 | require "split/user" 25 | require "split/version" 26 | require "split/zscore" 27 | require "split/engine" if defined?(::Rails::Engine) 28 | 29 | module Split 30 | extend self 31 | attr_accessor :configuration 32 | 33 | # Accepts: 34 | # 1. A redis URL (valid for `Redis.new(url: url)`) 35 | # 2. an options hash compatible with `Redis.new` 36 | # 3. or a valid Redis instance (one that responds to `#smembers`). Likely, 37 | # this will be an instance of either `Redis`, `Redis::Client`, 38 | # `Redis::DistRedis`, or `Redis::Namespace`. 39 | def redis=(server) 40 | @redis = if server.is_a?(String) 41 | Redis.new(url: server) 42 | elsif server.is_a?(Hash) 43 | Redis.new(server) 44 | elsif server.respond_to?(:smembers) 45 | server 46 | else 47 | raise ArgumentError, 48 | "You must supply a url, options hash or valid Redis connection instance" 49 | end 50 | end 51 | 52 | # Returns the current Redis connection. If none has been created, will 53 | # create a new one. 54 | def redis 55 | return @redis if @redis 56 | self.redis = self.configuration.redis 57 | self.redis 58 | end 59 | 60 | # Call this method to modify defaults in your initializers. 61 | # 62 | # @example 63 | # Split.configure do |config| 64 | # config.ignore_ip_addresses = '192.168.2.1' 65 | # end 66 | def configure 67 | self.configuration ||= Configuration.new 68 | yield(configuration) 69 | end 70 | 71 | def cache(namespace, key, &block) 72 | Split::Cache.fetch(namespace, key, &block) 73 | end 74 | end 75 | 76 | # Check to see if being run in a Rails application. If so, wait until before_initialize to run configuration so Gems that create ENV variables have the chance to initialize first. 77 | if defined?(::Rails::Railtie) 78 | class Split::Railtie < Rails::Railtie 79 | config.before_initialize { Split.configure { } } 80 | end 81 | else 82 | Split.configure { } 83 | end 84 | -------------------------------------------------------------------------------- /lib/split/algorithms.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "matrix" 4 | require "rubystats" 5 | 6 | module Split 7 | module Algorithms 8 | class << self 9 | def beta_distribution_rng(a, b) 10 | Rubystats::BetaDistribution.new(a, b).rng 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/split/algorithms/block_randomization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Selects alternative with minimum count of participants 4 | # If all counts are even (i.e. all are minimum), samples from all possible alternatives 5 | 6 | module Split 7 | module Algorithms 8 | module BlockRandomization 9 | class << self 10 | def choose_alternative(experiment) 11 | minimum_participant_alternatives(experiment.alternatives).sample 12 | end 13 | 14 | private 15 | def minimum_participant_alternatives(alternatives) 16 | alternatives_by_count = alternatives.group_by(&:participant_count) 17 | min_group = alternatives_by_count.min_by { |k, v| k } 18 | min_group.last 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/split/algorithms/weighted_sample.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module Algorithms 5 | module WeightedSample 6 | def self.choose_alternative(experiment) 7 | weights = experiment.alternatives.map(&:weight) 8 | 9 | total = weights.inject(:+) 10 | point = rand * total 11 | 12 | experiment.alternatives.zip(weights).each do |n, w| 13 | return n if w >= point 14 | point -= w 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/split/algorithms/whiplash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A multi-armed bandit implementation inspired by 4 | # @aaronsw and victorykit/whiplash 5 | 6 | module Split 7 | module Algorithms 8 | module Whiplash 9 | class << self 10 | def choose_alternative(experiment) 11 | experiment[best_guess(experiment.alternatives)] 12 | end 13 | 14 | private 15 | def arm_guess(participants, completions) 16 | a = [participants, 0].max 17 | b = [participants-completions, 0].max 18 | Split::Algorithms.beta_distribution_rng(a + fairness_constant, b + fairness_constant) 19 | end 20 | 21 | def best_guess(alternatives) 22 | guesses = {} 23 | alternatives.each do |alternative| 24 | guesses[alternative.name] = arm_guess(alternative.participant_count, alternative.all_completed_count) 25 | end 26 | gmax = guesses.values.max 27 | best = guesses.keys.select { |name| guesses[name] == gmax } 28 | best.sample 29 | end 30 | 31 | def fairness_constant 32 | 7 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/split/alternative.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Alternative 5 | attr_accessor :name 6 | attr_accessor :experiment_name 7 | attr_accessor :weight 8 | attr_accessor :recorded_info 9 | 10 | def initialize(name, experiment_name) 11 | @experiment_name = experiment_name 12 | if Hash === name 13 | @name = name.keys.first 14 | @weight = name.values.first 15 | else 16 | @name = name 17 | @weight = 1 18 | end 19 | @p_winner = 0.0 20 | end 21 | 22 | def to_s 23 | name 24 | end 25 | 26 | def goals 27 | self.experiment.goals 28 | end 29 | 30 | def p_winner(goal = nil) 31 | field = set_prob_field(goal) 32 | @p_winner = Split.redis.hget(key, field).to_f 33 | end 34 | 35 | def set_p_winner(prob, goal = nil) 36 | field = set_prob_field(goal) 37 | Split.redis.hset(key, field, prob.to_f) 38 | end 39 | 40 | def participant_count 41 | Split.redis.hget(key, "participant_count").to_i 42 | end 43 | 44 | def participant_count=(count) 45 | Split.redis.hset(key, "participant_count", count.to_i) 46 | end 47 | 48 | def completed_count(goal = nil) 49 | field = set_field(goal) 50 | Split.redis.hget(key, field).to_i 51 | end 52 | 53 | def all_completed_count 54 | if goals.empty? 55 | completed_count 56 | else 57 | goals.inject(completed_count) do |sum, g| 58 | sum + completed_count(g) 59 | end 60 | end 61 | end 62 | 63 | def unfinished_count 64 | participant_count - all_completed_count 65 | end 66 | 67 | def set_field(goal) 68 | field = "completed_count" 69 | field += ":" + goal unless goal.nil? 70 | field 71 | end 72 | 73 | def set_prob_field(goal) 74 | field = "p_winner" 75 | field += ":" + goal unless goal.nil? 76 | field 77 | end 78 | 79 | def set_completed_count(count, goal = nil) 80 | field = set_field(goal) 81 | Split.redis.hset(key, field, count.to_i) 82 | end 83 | 84 | def increment_participation 85 | Split.redis.hincrby key, "participant_count", 1 86 | end 87 | 88 | def increment_completion(goal = nil) 89 | field = set_field(goal) 90 | Split.redis.hincrby(key, field, 1) 91 | end 92 | 93 | def control? 94 | experiment.control.name == self.name 95 | end 96 | 97 | def conversion_rate(goal = nil) 98 | return 0 if participant_count.zero? 99 | (completed_count(goal).to_f)/participant_count.to_f 100 | end 101 | 102 | def experiment 103 | Split::ExperimentCatalog.find(experiment_name) 104 | end 105 | 106 | def z_score(goal = nil) 107 | # p_a = Pa = proportion of users who converted within the experiment split (conversion rate) 108 | # p_c = Pc = proportion of users who converted within the control split (conversion rate) 109 | # n_a = Na = the number of impressions within the experiment split 110 | # n_c = Nc = the number of impressions within the control split 111 | 112 | control = experiment.control 113 | alternative = self 114 | 115 | return "N/A" if control.name == alternative.name 116 | 117 | p_a = alternative.conversion_rate(goal) 118 | p_c = control.conversion_rate(goal) 119 | 120 | n_a = alternative.participant_count 121 | n_c = control.participant_count 122 | 123 | # can't calculate zscore for P(x) > 1 124 | return "N/A" if p_a > 1 || p_c > 1 125 | 126 | Split::Zscore.calculate(p_a, n_a, p_c, n_c) 127 | end 128 | 129 | def extra_info 130 | data = Split.redis.hget(key, "recorded_info") 131 | if data && data.length > 1 132 | begin 133 | JSON.parse(data) 134 | rescue 135 | {} 136 | end 137 | else 138 | {} 139 | end 140 | end 141 | 142 | def record_extra_info(k, value = 1) 143 | @recorded_info = self.extra_info || {} 144 | 145 | if value.kind_of?(Numeric) 146 | @recorded_info[k] ||= 0 147 | @recorded_info[k] += value 148 | else 149 | @recorded_info[k] = value 150 | end 151 | 152 | Split.redis.hset key, "recorded_info", (@recorded_info || {}).to_json 153 | end 154 | 155 | def save 156 | Split.redis.hsetnx key, "participant_count", 0 157 | Split.redis.hsetnx key, "completed_count", 0 158 | Split.redis.hsetnx key, "p_winner", p_winner 159 | Split.redis.hsetnx key, "recorded_info", (@recorded_info || {}).to_json 160 | end 161 | 162 | def validate! 163 | unless String === @name || hash_with_correct_values?(@name) 164 | raise ArgumentError, "Alternative must be a string" 165 | end 166 | end 167 | 168 | def reset 169 | Split.redis.hmset key, "participant_count", 0, "completed_count", 0, "recorded_info", "" 170 | unless goals.empty? 171 | goals.each do |g| 172 | field = "completed_count:#{g}" 173 | Split.redis.hset key, field, 0 174 | end 175 | end 176 | end 177 | 178 | def delete 179 | Split.redis.del(key) 180 | end 181 | 182 | private 183 | def hash_with_correct_values?(name) 184 | Hash === name && String === name.keys.first && Float(name.values.first) rescue false 185 | end 186 | 187 | def key 188 | "#{experiment_name}:#{name}" 189 | end 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /lib/split/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Cache 5 | def self.clear 6 | @cache = nil 7 | end 8 | 9 | def self.fetch(namespace, key) 10 | return yield unless Split.configuration.cache 11 | 12 | @cache ||= {} 13 | @cache[namespace] ||= {} 14 | 15 | value = @cache[namespace][key] 16 | return value if value 17 | 18 | @cache[namespace][key] = yield 19 | end 20 | 21 | def self.clear_key(key) 22 | @cache&.keys&.each do |namespace| 23 | @cache[namespace]&.delete(key) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/split/combined_experiments_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module CombinedExperimentsHelper 5 | def ab_combined_test(metric_descriptor, control = nil, *alternatives) 6 | return nil unless experiment = find_combined_experiment(metric_descriptor) 7 | raise(Split::InvalidExperimentsFormatError, "Unable to find experiment #{metric_descriptor} in configuration") if experiment[:combined_experiments].nil? 8 | 9 | alternative = nil 10 | weighted_alternatives = nil 11 | experiment[:combined_experiments].each do |combined_experiment| 12 | if alternative.nil? 13 | if control 14 | alternative = ab_test(combined_experiment, control, alternatives) 15 | else 16 | normalized_alternatives = Split::Configuration.new.normalize_alternatives(experiment[:alternatives]) 17 | alternative = ab_test(combined_experiment, normalized_alternatives[0], *normalized_alternatives[1]) 18 | end 19 | else 20 | weighted_alternatives ||= experiment[:alternatives].each_with_object({}) do |alt, memo| 21 | alt = Alternative.new(alt, experiment[:name]).name 22 | memo[alt] = (alt == alternative ? 1 : 0) 23 | end 24 | 25 | ab_test(combined_experiment, [weighted_alternatives]) 26 | end 27 | end 28 | alternative 29 | end 30 | 31 | def find_combined_experiment(metric_descriptor) 32 | raise(Split::InvalidExperimentsFormatError, "Invalid descriptor class (String or Symbol required)") unless metric_descriptor.class == String || metric_descriptor.class == Symbol 33 | raise(Split::InvalidExperimentsFormatError, "Enable configuration") unless Split.configuration.enabled 34 | raise(Split::InvalidExperimentsFormatError, "Enable `allow_multiple_experiments`") unless Split.configuration.allow_multiple_experiments 35 | Split.configuration.experiments[metric_descriptor.to_sym] 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/split/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class Configuration 5 | attr_accessor :ignore_ip_addresses 6 | attr_accessor :ignore_filter 7 | attr_accessor :db_failover 8 | attr_accessor :db_failover_on_db_error 9 | attr_accessor :db_failover_allow_parameter_override 10 | attr_accessor :allow_multiple_experiments 11 | attr_accessor :enabled 12 | attr_accessor :persistence 13 | attr_accessor :persistence_cookie_length 14 | attr_accessor :persistence_cookie_domain 15 | attr_accessor :algorithm 16 | attr_accessor :store_override 17 | attr_accessor :start_manually 18 | attr_accessor :reset_manually 19 | attr_accessor :on_trial 20 | attr_accessor :on_trial_choose 21 | attr_accessor :on_trial_complete 22 | attr_accessor :on_experiment_reset 23 | attr_accessor :on_experiment_delete 24 | attr_accessor :on_before_experiment_reset 25 | attr_accessor :on_experiment_winner_choose 26 | attr_accessor :on_before_experiment_delete 27 | attr_accessor :include_rails_helper 28 | attr_accessor :beta_probability_simulations 29 | attr_accessor :winning_alternative_recalculation_interval 30 | attr_accessor :redis 31 | attr_accessor :dashboard_pagination_default_per_page 32 | attr_accessor :cache 33 | 34 | attr_reader :experiments 35 | 36 | attr_writer :bots 37 | attr_writer :robot_regex 38 | 39 | def bots 40 | @bots ||= { 41 | # Indexers 42 | "AdsBot-Google" => "Google Adwords", 43 | "Baidu" => "Chinese search engine", 44 | "Baiduspider" => "Chinese search engine", 45 | "bingbot" => "Microsoft bing bot", 46 | "Butterfly" => "Topsy Labs", 47 | "Gigabot" => "Gigabot spider", 48 | "Googlebot" => "Google spider", 49 | "MJ12bot" => "Majestic-12 spider", 50 | "msnbot" => "Microsoft bot", 51 | "rogerbot" => "SeoMoz spider", 52 | "PaperLiBot" => "PaperLi is another content curation service", 53 | "Slurp" => "Yahoo spider", 54 | "Sogou" => "Chinese search engine", 55 | "spider" => "generic web spider", 56 | "UnwindFetchor" => "Gnip crawler", 57 | "WordPress" => "WordPress spider", 58 | "YandexAccessibilityBot" => "Yandex accessibility spider", 59 | "YandexBot" => "Yandex spider", 60 | "YandexMobileBot" => "Yandex mobile spider", 61 | "ZIBB" => "ZIBB spider", 62 | 63 | # HTTP libraries 64 | "Apache-HttpClient" => "Java http library", 65 | "AppEngine-Google" => "Google App Engine", 66 | "curl" => "curl unix CLI http client", 67 | "ColdFusion" => "ColdFusion http library", 68 | "EventMachine HttpClient" => "Ruby http library", 69 | "Go http package" => "Go http library", 70 | "Go-http-client" => "Go http library", 71 | "Java" => "Generic Java http library", 72 | "libwww-perl" => "Perl client-server library loved by script kids", 73 | "lwp-trivial" => "Another Perl library loved by script kids", 74 | "Python-urllib" => "Python http library", 75 | "PycURL" => "Python http library", 76 | "Test Certificate Info" => "C http library?", 77 | "Typhoeus" => "Ruby http library", 78 | "Wget" => "wget unix CLI http client", 79 | 80 | # URL expanders / previewers 81 | "awe.sm" => "Awe.sm URL expander", 82 | "bitlybot" => "bit.ly bot", 83 | "bot@linkfluence.net" => "Linkfluence bot", 84 | "facebookexternalhit" => "facebook bot", 85 | "Facebot" => "Facebook crawler", 86 | "Feedfetcher-Google" => "Google Feedfetcher", 87 | "https://developers.google.com/+/web/snippet" => "Google+ Snippet Fetcher", 88 | "LinkedInBot" => "LinkedIn bot", 89 | "LongURL" => "URL expander service", 90 | "NING" => "NING - Yet Another Twitter Swarmer", 91 | "Pinterestbot" => "Pinterest Bot", 92 | "redditbot" => "Reddit Bot", 93 | "ShortLinkTranslate" => "Link shortener", 94 | "Slackbot" => "Slackbot link expander", 95 | "TweetmemeBot" => "TweetMeMe Crawler", 96 | "Twitterbot" => "Twitter URL expander", 97 | "UnwindFetch" => "Gnip URL expander", 98 | "vkShare" => "VKontake Sharer", 99 | 100 | # Uptime monitoring 101 | "check_http" => "Nagios monitor", 102 | "GoogleStackdriverMonitoring" => "Google Cloud monitor", 103 | "NewRelicPinger" => "NewRelic monitor", 104 | "Panopta" => "Monitoring service", 105 | "Pingdom" => "Pingdom monitoring", 106 | "SiteUptime" => "Site monitoring services", 107 | "UptimeRobot" => "Monitoring service", 108 | 109 | # ??? 110 | "DigitalPersona Fingerprint Software" => "HP Fingerprint scanner", 111 | "ShowyouBot" => "Showyou iOS app spider", 112 | "ZyBorg" => "Zyborg? Hmmm....", 113 | "ELB-HealthChecker" => "ELB Health Check" 114 | } 115 | end 116 | 117 | def experiments=(experiments) 118 | raise InvalidExperimentsFormatError.new("Experiments must be a Hash") unless experiments.respond_to?(:keys) 119 | @experiments = experiments 120 | end 121 | 122 | def disabled? 123 | !enabled 124 | end 125 | 126 | def experiment_for(name) 127 | if normalized_experiments 128 | # TODO symbols 129 | normalized_experiments[name.to_sym] 130 | end 131 | end 132 | 133 | def metrics 134 | return @metrics if defined?(@metrics) 135 | @metrics = {} 136 | if self.experiments 137 | self.experiments.each do |key, value| 138 | metrics = value_for(value, :metric) rescue nil 139 | Array(metrics).each do |metric_name| 140 | if metric_name 141 | @metrics[metric_name.to_sym] ||= [] 142 | @metrics[metric_name.to_sym] << Split::Experiment.new(key) 143 | end 144 | end 145 | end 146 | end 147 | @metrics 148 | end 149 | 150 | def normalized_experiments 151 | return nil if @experiments.nil? 152 | 153 | experiment_config = {} 154 | @experiments.keys.each do |name| 155 | experiment_config[name.to_sym] = {} 156 | end 157 | 158 | @experiments.each do |experiment_name, settings| 159 | alternatives = if (alts = value_for(settings, :alternatives)) 160 | normalize_alternatives(alts) 161 | end 162 | 163 | experiment_data = { 164 | alternatives: alternatives, 165 | goals: value_for(settings, :goals), 166 | metadata: value_for(settings, :metadata), 167 | algorithm: value_for(settings, :algorithm), 168 | resettable: value_for(settings, :resettable) 169 | } 170 | 171 | experiment_data.each do |name, value| 172 | experiment_config[experiment_name.to_sym][name] = value if value != nil 173 | end 174 | end 175 | 176 | experiment_config 177 | end 178 | 179 | def normalize_alternatives(alternatives) 180 | given_probability, num_with_probability = alternatives.inject([0, 0]) do |a, v| 181 | p, n = a 182 | if percent = value_for(v, :percent) 183 | [p + percent, n + 1] 184 | else 185 | a 186 | end 187 | end 188 | 189 | num_without_probability = alternatives.length - num_with_probability 190 | unassigned_probability = ((100.0 - given_probability) / num_without_probability / 100.0) 191 | 192 | if num_with_probability.nonzero? 193 | alternatives = alternatives.map do |v| 194 | if (name = value_for(v, :name)) && (percent = value_for(v, :percent)) 195 | { name => percent / 100.0 } 196 | elsif name = value_for(v, :name) 197 | { name => unassigned_probability } 198 | else 199 | { v => unassigned_probability } 200 | end 201 | end 202 | 203 | [alternatives.shift, alternatives] 204 | else 205 | alternatives = alternatives.dup 206 | [alternatives.shift, alternatives] 207 | end 208 | end 209 | 210 | def robot_regex 211 | @robot_regex ||= /\b(?:#{escaped_bots.join('|')})\b|\A\W*\z/i 212 | end 213 | 214 | def initialize 215 | @ignore_ip_addresses = [] 216 | @ignore_filter = proc { |request| is_robot? || is_ignored_ip_address? } 217 | @db_failover = false 218 | @db_failover_on_db_error = proc { |error| } # e.g. use Rails logger here 219 | @on_experiment_reset = proc { |experiment| } 220 | @on_experiment_delete = proc { |experiment| } 221 | @on_before_experiment_reset = proc { |experiment| } 222 | @on_before_experiment_delete = proc { |experiment| } 223 | @on_experiment_winner_choose = proc { |experiment| } 224 | @db_failover_allow_parameter_override = false 225 | @allow_multiple_experiments = false 226 | @enabled = true 227 | @experiments = {} 228 | @persistence = Split::Persistence::SessionAdapter 229 | @persistence_cookie_length = 31536000 # One year from now 230 | @persistence_cookie_domain = nil 231 | @algorithm = Split::Algorithms::WeightedSample 232 | @include_rails_helper = true 233 | @beta_probability_simulations = 10000 234 | @winning_alternative_recalculation_interval = 60 * 60 * 24 # 1 day 235 | @redis = ENV.fetch(ENV.fetch("REDIS_PROVIDER", "REDIS_URL"), "redis://localhost:6379") 236 | @dashboard_pagination_default_per_page = 10 237 | end 238 | 239 | private 240 | def value_for(hash, key) 241 | if hash.kind_of?(Hash) 242 | hash.has_key?(key.to_s) ? hash[key.to_s] : hash[key.to_sym] 243 | end 244 | end 245 | 246 | def escaped_bots 247 | bots.map { |key, _| Regexp.escape(key) } 248 | end 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /lib/split/dashboard.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sinatra/base" 4 | require "split" 5 | require "bigdecimal" 6 | require "split/dashboard/helpers" 7 | require "split/dashboard/pagination_helpers" 8 | 9 | module Split 10 | class Dashboard < Sinatra::Base 11 | dir = File.dirname(File.expand_path(__FILE__)) 12 | 13 | set :views, "#{dir}/dashboard/views" 14 | set :public_folder, "#{dir}/dashboard/public" 15 | set :static, true 16 | set :method_override, true 17 | 18 | helpers Split::DashboardHelpers 19 | helpers Split::DashboardPaginationHelpers 20 | 21 | get "/" do 22 | # Display experiments without a winner at the top of the dashboard 23 | @experiments = Split::ExperimentCatalog.all_active_first 24 | @unintialized_experiments = Split.configuration.experiments.keys - @experiments.map(&:name) 25 | 26 | @metrics = Split::Metric.all 27 | 28 | # Display Rails Environment mode (or Rack version if not using Rails) 29 | if Object.const_defined?("Rails") && Rails.respond_to?(:env) 30 | @current_env = Rails.env.titlecase 31 | else 32 | rack_version = Rack.respond_to?(:version) ? Rack.version : Rack.release 33 | @current_env = "Rack: #{rack_version}" 34 | end 35 | erb :index 36 | end 37 | 38 | post "/initialize_experiment" do 39 | Split::ExperimentCatalog.find_or_create(params[:experiment]) unless params[:experiment].nil? || params[:experiment].empty? 40 | redirect url("/") 41 | end 42 | 43 | post "/force_alternative" do 44 | experiment = Split::ExperimentCatalog.find(params[:experiment]) 45 | alternative = Split::Alternative.new(params[:alternative], experiment.name) 46 | 47 | cookies = JSON.parse(request.cookies["split_override"]) rescue {} 48 | cookies[experiment.name] = alternative.name 49 | response.set_cookie("split_override", { value: cookies.to_json, path: "/" }) 50 | 51 | redirect url("/") 52 | end 53 | 54 | post "/experiment" do 55 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 56 | @alternative = Split::Alternative.new(params[:alternative], params[:experiment]) 57 | @experiment.winner = @alternative.name 58 | redirect url("/") 59 | end 60 | 61 | post "/start" do 62 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 63 | @experiment.start 64 | redirect url("/") 65 | end 66 | 67 | post "/reset" do 68 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 69 | @experiment.reset 70 | redirect url("/") 71 | end 72 | 73 | post "/reopen" do 74 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 75 | @experiment.reset_winner 76 | redirect url("/") 77 | end 78 | 79 | post "/update_cohorting" do 80 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 81 | case params[:cohorting_action].downcase 82 | when "enable" 83 | @experiment.enable_cohorting 84 | when "disable" 85 | @experiment.disable_cohorting 86 | end 87 | redirect url("/") 88 | end 89 | 90 | delete "/experiment" do 91 | @experiment = Split::ExperimentCatalog.find(params[:experiment]) 92 | @experiment.delete 93 | redirect url("/") 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/split/dashboard/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | module DashboardHelpers 5 | def h(text) 6 | Rack::Utils.escape_html(text) 7 | end 8 | 9 | def url(*path_parts) 10 | [ path_prefix, path_parts ].join("/").squeeze("/") 11 | end 12 | 13 | def path_prefix 14 | request.env["SCRIPT_NAME"] 15 | end 16 | 17 | def number_to_percentage(number, precision = 2) 18 | round(number * 100) 19 | end 20 | 21 | def round(number, precision = 2) 22 | begin 23 | BigDecimal(number.to_s) 24 | rescue ArgumentError 25 | BigDecimal(0) 26 | end.round(precision).to_f 27 | end 28 | 29 | def confidence_level(z_score) 30 | return z_score if z_score.is_a? String 31 | 32 | z = round(z_score.to_s.to_f, 3).abs 33 | 34 | if z >= 2.58 35 | "99% confidence" 36 | elsif z >= 1.96 37 | "95% confidence" 38 | elsif z >= 1.65 39 | "90% confidence" 40 | else 41 | "Insufficient confidence" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/split/dashboard/pagination_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "split/dashboard/paginator" 4 | 5 | module Split 6 | module DashboardPaginationHelpers 7 | def pagination_per 8 | default_per_page = Split.configuration.dashboard_pagination_default_per_page 9 | @pagination_per ||= (params[:per] || default_per_page).to_i 10 | end 11 | 12 | def page_number 13 | @page_number ||= (params[:page] || 1).to_i 14 | end 15 | 16 | def paginated(collection) 17 | Split::DashboardPaginator.new(collection, page_number, pagination_per).paginate 18 | end 19 | 20 | def pagination(collection) 21 | html = [] 22 | html << first_page_tag if show_first_page_tag? 23 | html << ellipsis_tag if show_first_ellipsis_tag? 24 | html << prev_page_tag if show_prev_page_tag? 25 | html << current_page_tag 26 | html << next_page_tag if show_next_page_tag?(collection) 27 | html << ellipsis_tag if show_last_ellipsis_tag?(collection) 28 | html << last_page_tag(collection) if show_last_page_tag?(collection) 29 | html.join 30 | end 31 | 32 | private 33 | def show_first_page_tag? 34 | page_number > 2 35 | end 36 | 37 | def first_page_tag 38 | %Q(1) 39 | end 40 | 41 | def show_first_ellipsis_tag? 42 | page_number >= 4 43 | end 44 | 45 | def ellipsis_tag 46 | "..." 47 | end 48 | 49 | def show_prev_page_tag? 50 | page_number > 1 51 | end 52 | 53 | def prev_page_tag 54 | %Q(#{page_number - 1}) 55 | end 56 | 57 | def current_page_tag 58 | "#{page_number}" 59 | end 60 | 61 | def show_next_page_tag?(collection) 62 | (page_number * pagination_per) < collection.count 63 | end 64 | 65 | def next_page_tag 66 | %Q(#{page_number + 1}) 67 | end 68 | 69 | def show_last_ellipsis_tag?(collection) 70 | (total_pages(collection) - page_number) >= 3 71 | end 72 | 73 | def total_pages(collection) 74 | collection.count / pagination_per + ((collection.count % pagination_per).zero? ? 0 : 1) 75 | end 76 | 77 | def show_last_page_tag?(collection) 78 | page_number < (total_pages(collection) - 1) 79 | end 80 | 81 | def last_page_tag(collection) 82 | total = total_pages(collection) 83 | %Q(#{total}) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/split/dashboard/paginator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Split 4 | class DashboardPaginator 5 | def initialize(collection, page_number, per) 6 | @collection = collection 7 | @page_number = page_number 8 | @per = per 9 | end 10 | 11 | def paginate 12 | to = @page_number * @per 13 | from = to - @per 14 | @collection[from...to] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/split/dashboard/public/dashboard-filtering.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('#filter').on('keyup', function() { 3 | $input = $(this); 4 | 5 | if ($input.val() === '') { 6 | $('div.experiment').show(); 7 | return false; 8 | } 9 | 10 | $('div.experiment').hide(); 11 | selector = 'div.experiment[data-name*="' + $input.val() + '"]'; 12 | $(selector).show(); 13 | }); 14 | 15 | $('#clear-filter').on('click', function() { 16 | $('#filter').val(''); 17 | $('div.experiment').show(); 18 | $('#toggle-active').val('Hide active'); 19 | $('#toggle-completed').val('Hide completed'); 20 | }); 21 | 22 | $('#toggle-active').on('click', function() { 23 | $button = $(this); 24 | if ($button.val() === 'Hide active') { 25 | $button.val('Show active'); 26 | } else { 27 | $button.val('Hide active'); 28 | } 29 | 30 | $('div.experiment[data-complete="false"]').toggle(); 31 | }); 32 | 33 | $('#toggle-completed').on('click', function() { 34 | $button = $(this); 35 | if ($button.val() === 'Hide completed') { 36 | $button.val('Show completed'); 37 | } else { 38 | $button.val('Hide completed'); 39 | } 40 | 41 | $('div.experiment[data-complete="true"]').toggle(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /lib/split/dashboard/public/dashboard.js: -------------------------------------------------------------------------------- 1 | function confirmReset() { 2 | var agree = confirm("This will delete all data for this experiment?"); 3 | return agree ? true : false; 4 | } 5 | 6 | function confirmDelete() { 7 | var agree = confirm("Are you sure you want to delete this experiment and all its data?"); 8 | return agree ? true : false; 9 | } 10 | 11 | function confirmWinner() { 12 | var agree = confirm("This will now be returned for all users. Are you sure?"); 13 | return agree ? true : false; 14 | } 15 | 16 | function confirmStep(step) { 17 | var agree = confirm(step); 18 | return agree ? true : false; 19 | } 20 | 21 | function confirmReopen() { 22 | var agree = confirm("This will reopen the experiment. Are you sure?"); 23 | return agree ? true : false; 24 | } 25 | 26 | function confirmEnableCohorting(){ 27 | var agree = confirm("This will enable the cohorting of the experiment. Are you sure?"); 28 | return agree ? true : false; 29 | } 30 | 31 | function confirmDisableCohorting(){ 32 | var agree = confirm("This will disable the cohorting of the experiment. Note: Existing participants will continue to receive their alternative and may continue to convert. Are you sure?"); 33 | return agree ? true : false; 34 | } 35 | -------------------------------------------------------------------------------- /lib/split/dashboard/public/reset.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, 2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 3 | a, abbr, acronym, address, big, cite, code, 4 | del, dfn, em, font, img, ins, kbd, q, s, samp, 5 | small, strike, strong, sub, sup, tt, var, 6 | dl, dt, dd, ul, li, 7 | form, label, legend, 8 | table, caption, tbody, tfoot, thead, tr, th, td { 9 | margin: 0; 10 | padding: 0; 11 | border: 0; 12 | outline: 0; 13 | font-weight: inherit; 14 | font-style: normal; 15 | font-size: 100%; 16 | font-family: inherit; 17 | } 18 | 19 | :focus { 20 | outline: 0; 21 | } 22 | 23 | body { 24 | line-height: 1; 25 | } 26 | 27 | ul { 28 | list-style: none; 29 | } 30 | 31 | table { 32 | border-collapse: collapse; 33 | border-spacing: 0; 34 | } 35 | 36 | caption, th, td { 37 | text-align: left; 38 | font-weight: normal; 39 | } 40 | 41 | blockquote:before, blockquote:after, 42 | q:before, q:after { 43 | content: ""; 44 | } 45 | 46 | blockquote, q { 47 | quotes: "" ""; 48 | } -------------------------------------------------------------------------------- /lib/split/dashboard/public/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | background: #efefef; 3 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 4 | font-size: 13px; 5 | } 6 | 7 | body { 8 | padding: 0 10px; 9 | margin: 10px auto 0; 10 | } 11 | 12 | .header { 13 | background: #ededed; 14 | background: -webkit-gradient(linear, left top, left bottom, 15 | color-stop(0%,#576a76), 16 | color-stop(100%,#4d5256)); 17 | background: -moz-linear-gradient(top, #576076 0%, #414e58 100%); 18 | background: -webkit-linear-gradient(top, #576a76 0%, #414e58 100%); 19 | background: -o-linear-gradient(top, #576a76 0%, #414e58 100%); 20 | background: -ms-linear-gradient(top, #576a76 0%, #414e58 100%); 21 | background: linear-gradient(top, #576a76 0%, #414e58 100%); 22 | border-bottom: 1px solid #fff; 23 | -moz-border-radius-topleft: 5px; 24 | -webkit-border-top-left-radius: 5px; 25 | border-top-left-radius: 5px; 26 | -moz-border-radius-topright: 5px; 27 | -webkit-border-top-right-radius:5px; 28 | border-top-right-radius: 5px; 29 | 30 | overflow:hidden; 31 | padding: 10px 5%; 32 | text-shadow:0 1px 0 #000; 33 | } 34 | 35 | .header h1 { 36 | color: #eee; 37 | float:left; 38 | font-size:1.2em; 39 | font-weight:normal; 40 | margin:2px 30px 0 0; 41 | } 42 | 43 | .header ul li { 44 | display: inline; 45 | } 46 | 47 | .header ul li a { 48 | color: #eee; 49 | text-decoration: none; 50 | margin-right: 10px; 51 | display: inline-block; 52 | padding: 4px 8px; 53 | -moz-border-radius: 10px; 54 | -webkit-border-radius:10px; 55 | border-radius: 10px; 56 | 57 | } 58 | 59 | .header ul li a:hover { 60 | background: rgba(255,255,255,0.1); 61 | } 62 | 63 | .header ul li a:active { 64 | -moz-box-shadow: inset 0 1px 0 rgba(0,0,0,0.2); 65 | -webkit-box-shadow:inset 0 1px 0 rgba(0,0,0,0.2); 66 | box-shadow: inset 0 1px 0 rgba(0,0,0,0.2); 67 | } 68 | 69 | .header ul li.current a { 70 | background: rgba(255,255,255,0.1); 71 | -moz-box-shadow: inset 0 1px 0 rgba(0,0,0,0.2); 72 | -webkit-box-shadow:inset 0 1px 0 rgba(0,0,0,0.2); 73 | box-shadow: inset 0 1px 0 rgba(0,0,0,0.2); 74 | color: #fff; 75 | } 76 | 77 | .header p.environment { 78 | clear: both; 79 | padding: 10px 0 0 0; 80 | color: #BBB; 81 | font-style: italic; 82 | float: right; 83 | } 84 | 85 | #main { 86 | padding: 10px 5%; 87 | background: #f9f9f9; 88 | border:1px solid #ccc; 89 | border-top:none; 90 | -moz-box-shadow: 0 3px 10px rgba(0,0,0,0.2); 91 | -webkit-box-shadow:0 3px 10px rgba(0,0,0,0.2); 92 | box-shadow: 0 3px 10px rgba(0,0,0,0.2); 93 | overflow: hidden; 94 | } 95 | 96 | #main .logo { 97 | float: right; 98 | margin: 10px; 99 | } 100 | 101 | #main span.hl { 102 | background: #efefef; 103 | padding: 2px; 104 | } 105 | 106 | #main h1 { 107 | margin: 10px 0; 108 | font-size: 190%; 109 | font-weight: bold; 110 | color: #0080FF; 111 | } 112 | 113 | #main table { 114 | width: 100%; 115 | margin:0 0 10px; 116 | } 117 | 118 | #main table tr td, #main table tr th { 119 | border-bottom: 1px solid #ccc; 120 | padding: 6px; 121 | } 122 | 123 | #main table tr th { 124 | background: #efefef; 125 | color: #888; 126 | font-size: 80%; 127 | text-transform:uppercase; 128 | } 129 | 130 | #main table tr td.no-data { 131 | text-align: center; 132 | padding: 40px 0; 133 | color: #999; 134 | font-style: italic; 135 | font-size: 130%; 136 | } 137 | 138 | #main a { 139 | color: #111; 140 | } 141 | 142 | #main p { 143 | margin: 5px 0; 144 | } 145 | 146 | #main p.intro { 147 | margin-bottom: 15px; 148 | font-size: 85%; 149 | color: #999; 150 | margin-top: 0; 151 | line-height: 1.3; 152 | } 153 | 154 | #main h1.wi { 155 | margin-bottom: 5px; 156 | } 157 | 158 | #main p.sub { 159 | font-size: 95%; 160 | color: #999; 161 | } 162 | 163 | .experiment { 164 | background:#fff; 165 | border: 1px solid #eee; 166 | border-bottom:none; 167 | margin:30px 0; 168 | } 169 | 170 | .experiment_with_goal { 171 | margin: -32px 0 30px 0; 172 | } 173 | 174 | .experiment .experiment-header { 175 | background: #f4f4f4; 176 | background: -webkit-gradient(linear, left top, left bottom, 177 | color-stop(0%,#f4f4f4), 178 | color-stop(100%,#e0e0e0)); 179 | background: -moz-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%); 180 | background: -webkit-linear-gradient(top, #f4f4f4 0%, #e0e0e0 100%); 181 | background: -o-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%); 182 | background: -ms-linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%); 183 | background: linear-gradient (top, #f4f4f4 0%, #e0e0e0 100%); 184 | border-top:1px solid #fff; 185 | overflow:hidden; 186 | padding:0 10px; 187 | } 188 | 189 | .experiment h2 { 190 | color:#888; 191 | margin: 12px 0 12px 0; 192 | font-size: 1em; 193 | font-weight:bold; 194 | float:left; 195 | text-shadow:0 1px 0 rgba(255,255,255,0.8); 196 | } 197 | 198 | .experiment h2 .goal { 199 | font-style: italic; 200 | } 201 | 202 | .experiment h2 .version { 203 | font-style:italic; 204 | font-size:0.8em; 205 | color:#bbb; 206 | font-weight:normal; 207 | } 208 | 209 | .experiment table em{ 210 | font-style:italic; 211 | font-size:0.9em; 212 | color:#bbb; 213 | } 214 | 215 | .experiment table .totals td { 216 | background: #eee; 217 | font-weight: bold; 218 | } 219 | 220 | #footer { 221 | padding: 10px 5%; 222 | color: #999; 223 | font-size: 85%; 224 | line-height: 1.5; 225 | padding-top: 10px; 226 | } 227 | 228 | #footer p a { 229 | color: #999; 230 | } 231 | 232 | .inline-controls { 233 | float:right; 234 | } 235 | 236 | .inline-controls small { 237 | color: #888; 238 | font-size: 11px; 239 | } 240 | 241 | .inline-controls form { 242 | display: inline-block; 243 | font-size: 10px; 244 | line-height: 38px; 245 | } 246 | 247 | .inline-controls input { 248 | margin-left: 10px; 249 | } 250 | 251 | .worse, .better { 252 | color: #773F3F; 253 | font-size: 10px; 254 | font-weight:bold; 255 | } 256 | 257 | .better { 258 | color: #408C48; 259 | } 260 | 261 | .experiment a.button, .experiment button, .experiment input[type="submit"] { 262 | padding: 4px 10px; 263 | overflow: hidden; 264 | background: #d8dae0; 265 | -moz-box-shadow: 0 1px 0 rgba(0,0,0,0.5); 266 | -webkit-box-shadow:0 1px 0 rgba(0,0,0,0.5); 267 | box-shadow: 0 1px 0 rgba(0,0,0,0.5); 268 | border:none; 269 | -moz-border-radius: 30px; 270 | -webkit-border-radius:30px; 271 | border-radius: 30px; 272 | color:#2e3035; 273 | cursor: pointer; 274 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 275 | text-decoration: none; 276 | text-shadow:0 1px 0 rgba(255,255,255,0.8); 277 | -moz-user-select: none; 278 | -webkit-user-select:none; 279 | user-select: none; 280 | white-space: nowrap; 281 | } 282 | a.button:hover, button:hover, input[type="submit"]:hover, 283 | a.button:focus, button:focus, input[type="submit"]:focus{ 284 | background:#bbbfc7; 285 | } 286 | a.button:active, button:active, input[type="submit"]:active{ 287 | -moz-box-shadow: inset 0 0 4px #484d57; 288 | -webkit-box-shadow:inset 0 0 4px #484d57; 289 | box-shadow: inset 0 0 4px #484d57; 290 | position:relative; 291 | top:1px; 292 | } 293 | 294 | a.button.red, button.red, input[type="submit"].red, 295 | a.button.green, button.green, input[type="submit"].green { 296 | color:#fff; 297 | text-shadow:0 1px 0 rgba(0,0,0,0.4); 298 | } 299 | 300 | a.button.red, button.red, input[type="submit"].red { 301 | background:#a56d6d; 302 | } 303 | a.button.red:hover, button.red:hover, input[type="submit"].red:hover, 304 | a.button.red:focus, button.red:focus, input[type="submit"].red:focus { 305 | background:#895C5C; 306 | } 307 | a.button.green, button.green, input[type="submit"].green { 308 | background:#8daa92; 309 | } 310 | a.button.green:hover, button.green:hover, input[type="submit"].green:hover, 311 | a.button.green:focus, button.green:focus, input[type="submit"].green:focus { 312 | background:#768E7A; 313 | } 314 | 315 | .dashboard-controls input, .dashboard-controls select { 316 | padding: 10px; 317 | } 318 | 319 | .dashboard-controls-bottom { 320 | margin-top: 10px; 321 | } 322 | 323 | .pagination { 324 | text-align: center; 325 | font-size: 15px; 326 | } 327 | 328 | .pagination a, .paginaton span { 329 | display: inline-block; 330 | padding: 5px; 331 | } 332 | 333 | .divider { 334 | display: inline-block; 335 | margin-left: 10px; 336 | } 337 | -------------------------------------------------------------------------------- /lib/split/dashboard/views/_controls.erb: -------------------------------------------------------------------------------- 1 | <% if experiment.has_winner? %> 2 |
5 | <% else %> 6 | <% if experiment.cohorting_disabled? %> 7 | 11 | <% else %> 12 | 16 | <% end %> 17 | <% end %> 18 | | 19 | <% if experiment.start_time %> 20 | 23 | <% else%> 24 | 27 | <% end %> 28 | 32 | -------------------------------------------------------------------------------- /lib/split/dashboard/views/_experiment.erb: -------------------------------------------------------------------------------- 1 | <% unless goal.nil? %> 2 | <% experiment_class = "experiment experiment_with_goal" %> 3 | <% else %> 4 | <% experiment_class = "experiment" %> 5 | <% end %> 6 | 7 | <% experiment.calc_winning_alternatives %> 8 | <% 9 | extra_columns = [] 10 | experiment.alternatives.each do |alternative| 11 | extra_info = alternative.extra_info || {} 12 | extra_columns += extra_info.keys 13 | end 14 | 15 | extra_columns.uniq! 16 | summary_texts = {} 17 | extra_columns.each do |column| 18 | extra_infos = experiment.alternatives.map(&:extra_info).select{|extra_info| extra_info && extra_info[column] } 19 | 20 | if extra_infos.length > 0 && extra_infos.all? { |extra_info| extra_info[column].kind_of?(Numeric) } 21 | summary_texts[column] = extra_infos.inject(0){|sum, extra_info| sum += extra_info[column]} 22 | else 23 | summary_texts[column] = "N/A" 24 | end 25 | end 26 | %> 27 | 28 | 29 |Alternative Name | 51 |Participants | 52 |Non-finished | 53 |Completed | 54 |Conversion Rate | 55 | <% extra_columns.each do |column| %> 56 |<%= column %> | 57 | <% end %> 58 |59 | 65 | | 66 |Finish | 67 |
---|---|---|---|---|---|---|---|
73 | <%= alternative.name %> 74 | <% if alternative.control? %> 75 | control 76 | <% end %> 77 | 81 | | 82 |<%= alternative.participant_count %> | 83 |<%= alternative.unfinished_count %> | 84 |<%= alternative.completed_count(goal) %> | 85 |86 | <%= number_to_percentage(alternative.conversion_rate(goal)) %>% 87 | <% if experiment.control.conversion_rate(goal) > 0 && !alternative.control? %> 88 | <% if alternative.conversion_rate(goal) > experiment.control.conversion_rate(goal) %> 89 | 90 | +<%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>% 91 | 92 | <% elsif alternative.conversion_rate(goal) < experiment.control.conversion_rate(goal) %> 93 | 94 | <%= number_to_percentage((alternative.conversion_rate(goal)/experiment.control.conversion_rate(goal))-1) %>% 95 | 96 | <% end %> 97 | <% end %> 98 | | 99 | 108 | <% extra_columns.each do |column| %> 109 |<%= alternative.extra_info && alternative.extra_info[column] %> | 110 | <% end %> 111 |
112 |
113 | <%= confidence_level(alternative.z_score(goal)) %>
114 |
116 | 115 |
117 | <%= number_to_percentage(round(alternative.p_winner(goal), 3)) %>%
118 |
119 | |
120 | 121 | <% if experiment.has_winner? %> 122 | <% if experiment.winner.name == alternative.name %> 123 | Winner 124 | <% else %> 125 | Loser 126 | <% end %> 127 | <% else %> 128 | 132 | <% end %> 133 | | 134 |
Totals | 143 |<%= total_participants %> | 144 |<%= total_unfinished %> | 145 |<%= total_completed %> | 146 |N/A | 147 | <% extra_columns.each do |column| %> 148 |149 | <%= summary_texts[column] %> 150 | | 151 | <% end %> 152 |N/A | 153 |N/A | 154 |
The list below contains all the registered experiments along with the number of test participants, completed and conversion rate currently in the system.
3 | 4 |No experiments have started yet, you need to define them in your code and introduce them to your users.
27 |Check out the Readme for more help getting started.
28 | <% end %> 29 | 30 |<%= @current_env %>
17 |