laboratory 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/linters.yml +24 -0
- data/.rubocop.yml +11 -0
- data/.rubocop_defaults.yml +3963 -0
- data/Gemfile +2 -2
- data/Gemfile.lock +1 -1
- data/README.md +40 -2
- data/Rakefile +3 -3
- data/bin/console +4 -4
- data/laboratory.gemspec +8 -5
- data/lib/laboratory/adapters/redis_adapter.rb +34 -15
- data/lib/laboratory/algorithms/random.rb +1 -1
- data/lib/laboratory/calculations/confidence_level.rb +7 -4
- data/lib/laboratory/calculations/z_score.rb +17 -15
- data/lib/laboratory/experiment/analysis_summary.rb +7 -5
- data/lib/laboratory/experiment/variant.rb +5 -1
- data/lib/laboratory/experiment.rb +57 -21
- data/lib/laboratory/ui/helpers.rb +12 -7
- data/lib/laboratory/ui.rb +4 -4
- data/lib/laboratory/version.rb +1 -1
- metadata +15 -12
    
        data/Gemfile
    CHANGED
    
    
    
        data/Gemfile.lock
    CHANGED
    
    
    
        data/README.md
    CHANGED
    
    | @@ -13,6 +13,18 @@ Laboratory is an A/B testing and Feature Flag framework for Rails. It's focused | |
| 13 13 |  | 
| 14 14 | 
             
            Laboratory builds upon great work from other gems, in particular [Split](https://github.com/splitrb/split).
         | 
| 15 15 |  | 
| 16 | 
            +
            Laboratory is in active development, see the bottom for a todo list.
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            ## Preview of UI Interface
         | 
| 19 | 
            +
             | 
| 20 | 
            +
            ### Viewing and Analysing experiments
         | 
| 21 | 
            +
             | 
| 22 | 
            +
            
         | 
| 23 | 
            +
             | 
| 24 | 
            +
            ### Editting an experiment
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            
         | 
| 27 | 
            +
             | 
| 16 28 | 
             
            ## Installation
         | 
| 17 29 |  | 
| 18 30 | 
             
            Add this line to your application's Gemfile:
         | 
| @@ -126,6 +138,29 @@ experiment.record_event!('completed') | |
| 126 138 |  | 
| 127 139 | 
             
            Note the `#record_event!` method also takes an optional user parameter should you want to define the user specifically in this case. It defaults to a user with the current_user_id defined in the Laboratory configuration.
         | 
| 128 140 |  | 
| 141 | 
            +
            ### Temporarily overriding experiment variants
         | 
| 142 | 
            +
             | 
| 143 | 
            +
            Sometimes, when QA'ing or developing an experiment, you'll want to easily switch between variants without having to jump into the console. This can be managed via a url parameter by adding the following snippet to your application controller (this example is for Rails, but a similar approach would work for other frameworks):
         | 
| 144 | 
            +
             | 
| 145 | 
            +
            ```ruby
         | 
| 146 | 
            +
             | 
| 147 | 
            +
            around_action :override_laboratory_experiments!
         | 
| 148 | 
            +
             | 
| 149 | 
            +
            def override_laboratory_experiments!
         | 
| 150 | 
            +
              Laboratory::Experiment.override!(params[:exp])
         | 
| 151 | 
            +
              yield
         | 
| 152 | 
            +
              Laboratory::Experiment.clear_overrides!
         | 
| 153 | 
            +
            end
         | 
| 154 | 
            +
            ```
         | 
| 155 | 
            +
             | 
| 156 | 
            +
            This then allows you navigate to a urls like:
         | 
| 157 | 
            +
             | 
| 158 | 
            +
            http://yourwebsite.com?exp[blue_button_ab_test]=variant_a
         | 
| 159 | 
            +
             | 
| 160 | 
            +
            and
         | 
| 161 | 
            +
             | 
| 162 | 
            +
            http://yourwebsite.com?exp[blue_button_ab_test]=control
         | 
| 163 | 
            +
             | 
| 129 164 | 
             
            ### Using the Laboratory UI
         | 
| 130 165 |  | 
| 131 166 | 
             
            It's easy to analyse and manage your experiment from the dashboard. In routes.rb, mount the dashboard behind your appropriate authentication layer (this example uses Devise):
         | 
| @@ -208,8 +243,6 @@ user.variant_for_experiment(experiment) # Note: This returns nil if the user is | |
| 208 243 |  | 
| 209 244 | 
             
            **Updating an experiment's variants**:
         | 
| 210 245 |  | 
| 211 | 
            -
            Note: This would wipe all users from the experiment.
         | 
| 212 | 
            -
             | 
| 213 246 | 
             
            ```ruby
         | 
| 214 247 | 
             
            experiment = Laboratory::Experiment.find('blue_button_cta')
         | 
| 215 248 | 
             
            control = experiment.variants.find { |variant| variant.id == 'control' }
         | 
| @@ -252,6 +285,11 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run | |
| 252 285 |  | 
| 253 286 | 
             
            To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
         | 
| 254 287 |  | 
| 288 | 
            +
            ### Todo List
         | 
| 289 | 
            +
             | 
| 290 | 
            +
            - [ ] Test in a multi-threaded puma environment
         | 
| 291 | 
            +
            - [ ] Test performance in a A/A test on production
         | 
| 292 | 
            +
             | 
| 255 293 | 
             
            ## Contributing
         | 
| 256 294 |  | 
| 257 295 | 
             
            Bug reports and pull requests are welcome on GitHub at https://github.com/butternutbox/laboratory. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to that.
         | 
    
        data/Rakefile
    CHANGED
    
    
    
        data/bin/console
    CHANGED
    
    | @@ -1,14 +1,14 @@ | |
| 1 1 | 
             
            #!/usr/bin/env ruby
         | 
| 2 2 |  | 
| 3 | 
            -
            require  | 
| 4 | 
            -
            require  | 
| 3 | 
            +
            require 'bundler/setup'
         | 
| 4 | 
            +
            require 'laboratory'
         | 
| 5 5 |  | 
| 6 6 | 
             
            # You can add fixtures and/or initialization code here to make experimenting
         | 
| 7 7 | 
             
            # with your gem easier. You can also use a different console, if you like.
         | 
| 8 8 |  | 
| 9 9 | 
             
            # (If you use this, don't forget to add pry to your Gemfile!)
         | 
| 10 | 
            -
            # require  | 
| 10 | 
            +
            # require 'pry'
         | 
| 11 11 | 
             
            # Pry.start
         | 
| 12 12 |  | 
| 13 | 
            -
            require  | 
| 13 | 
            +
            require 'irb'
         | 
| 14 14 | 
             
            IRB.start(__FILE__)
         | 
    
        data/laboratory.gemspec
    CHANGED
    
    | @@ -7,7 +7,7 @@ Gem::Specification.new do |spec| | |
| 7 7 | 
             
              spec.email = ['niall@butternutbox.com']
         | 
| 8 8 |  | 
| 9 9 | 
             
              spec.summary = 'Laboratory: An A/B Testing and Feature Flag system for Ruby'
         | 
| 10 | 
            -
              spec.description = ' | 
| 10 | 
            +
              spec.description = 'An A/B Testing and Feature Flag system for Ruby'
         | 
| 11 11 | 
             
              spec.homepage = 'https://github.com/butternutbox/laboratory'
         | 
| 12 12 | 
             
              spec.license = 'MIT'
         | 
| 13 13 | 
             
              spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
         | 
| @@ -17,9 +17,12 @@ Gem::Specification.new do |spec| | |
| 17 17 | 
             
              spec.metadata['changelog_uri'] = 'https://github.com/butternutbox/laboratory/releases'
         | 
| 18 18 |  | 
| 19 19 | 
             
              # Specify which files should be added to the gem when it is released.
         | 
| 20 | 
            -
              # The `git ls-files -z` loads the files in the RubyGem that have been added | 
| 20 | 
            +
              # The `git ls-files -z` loads the files in the RubyGem that have been added
         | 
| 21 | 
            +
              # into git.
         | 
| 21 22 | 
             
              spec.files = Dir.chdir(File.expand_path(__dir__)) do
         | 
| 22 | 
            -
                `git ls-files -z`.split("\x0").reject  | 
| 23 | 
            +
                `git ls-files -z`.split("\x0").reject do |f|
         | 
| 24 | 
            +
                  f.match(%r{^(test|spec|features)/})
         | 
| 25 | 
            +
                end
         | 
| 23 26 | 
             
              end
         | 
| 24 27 | 
             
              spec.bindir = 'exe'
         | 
| 25 28 | 
             
              spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
         | 
| @@ -28,7 +31,7 @@ Gem::Specification.new do |spec| | |
| 28 31 | 
             
              spec.add_dependency 'redis', '>= 2.1'
         | 
| 29 32 | 
             
              spec.add_dependency 'sinatra', '>= 1.2.6'
         | 
| 30 33 |  | 
| 31 | 
            -
              spec.add_development_dependency 'rack-test',   '~> 1.1'
         | 
| 32 | 
            -
              spec.add_development_dependency 'rspec', '~> 3.8'
         | 
| 33 34 | 
             
              spec.add_development_dependency 'fakeredis', '~> 0.8'
         | 
| 35 | 
            +
              spec.add_development_dependency 'rack-test', '~> 1.1'
         | 
| 36 | 
            +
              spec.add_development_dependency 'rspec', '~> 3.8'
         | 
| 34 37 | 
             
            end
         | 
| @@ -1,23 +1,26 @@ | |
| 1 1 | 
             
            module Laboratory
         | 
| 2 2 | 
             
              module Adapters
         | 
| 3 | 
            -
                class RedisAdapter
         | 
| 3 | 
            +
                class RedisAdapter # rubocop:disable Metrics/ClassLength
         | 
| 4 4 | 
             
                  attr_reader :redis
         | 
| 5 5 |  | 
| 6 | 
            -
                  ALL_EXPERIMENTS_KEYS_KEY = ' | 
| 6 | 
            +
                  ALL_EXPERIMENTS_KEYS_KEY = 'LABORATORY_ALL_EXPERIMENT_KEYS'.freeze
         | 
| 7 7 |  | 
| 8 8 | 
             
                  def initialize(url:)
         | 
| 9 9 | 
             
                    @redis = Redis.new(url: url)
         | 
| 10 | 
            -
             | 
| 11 | 
            -
                    if !redis.get(ALL_EXPERIMENTS_KEYS_KEY)
         | 
| 12 | 
            -
                      redis.set(ALL_EXPERIMENTS_KEYS_KEY, [])
         | 
| 13 | 
            -
                    end
         | 
| 14 10 | 
             
                  end
         | 
| 15 11 |  | 
| 16 12 | 
             
                  def write(experiment)
         | 
| 17 | 
            -
                     | 
| 18 | 
            -
                     | 
| 19 | 
            -
                     | 
| 20 | 
            -
             | 
| 13 | 
            +
                    key = redis_key(experiment_id: experiment.id)
         | 
| 14 | 
            +
                    json = experiment_to_json(experiment)
         | 
| 15 | 
            +
                    redis.set(key, json)
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                    # Write to ALL_EXPERIMENTS_KEYS_KEY if it isn't already there.
         | 
| 18 | 
            +
                    experiment_ids = fetch_all_experiment_ids
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                    unless experiment_ids.include?(experiment.id)
         | 
| 21 | 
            +
                      experiment_ids << experiment.id
         | 
| 22 | 
            +
                    end
         | 
| 23 | 
            +
             | 
| 21 24 | 
             
                    redis.set(ALL_EXPERIMENTS_KEYS_KEY, experiment_ids.to_json)
         | 
| 22 25 | 
             
                  end
         | 
| 23 26 |  | 
| @@ -26,11 +29,12 @@ module Laboratory | |
| 26 29 | 
             
                    response = redis.get(key)
         | 
| 27 30 |  | 
| 28 31 | 
             
                    return nil if response.nil?
         | 
| 32 | 
            +
             | 
| 29 33 | 
             
                    parse_json_to_experiment(JSON.parse(response))
         | 
| 30 34 | 
             
                  end
         | 
| 31 35 |  | 
| 32 36 | 
             
                  def read_all
         | 
| 33 | 
            -
                    experiment_ids =  | 
| 37 | 
            +
                    experiment_ids = fetch_all_experiment_ids
         | 
| 34 38 | 
             
                    experiment_ids.map do |experiment_id|
         | 
| 35 39 | 
             
                      read(experiment_id)
         | 
| 36 40 | 
             
                    end
         | 
| @@ -41,18 +45,27 @@ module Laboratory | |
| 41 45 | 
             
                    redis.del(key)
         | 
| 42 46 |  | 
| 43 47 | 
             
                    # Remove from ALL_EXPERIMENTS_KEY_KEY
         | 
| 44 | 
            -
                    experiment_ids =  | 
| 48 | 
            +
                    experiment_ids = fetch_all_experiment_ids
         | 
| 45 49 | 
             
                    experiment_ids.delete(experiment_id)
         | 
| 46 50 | 
             
                    redis.set(ALL_EXPERIMENTS_KEYS_KEY, experiment_ids.to_json)
         | 
| 47 51 | 
             
                  end
         | 
| 48 52 |  | 
| 49 53 | 
             
                  def delete_all
         | 
| 50 | 
            -
                    experiment_ids =  | 
| 54 | 
            +
                    experiment_ids = fetch_all_experiment_ids
         | 
| 51 55 | 
             
                    experiment_ids.each { |experiment_id| delete(experiment_id) }
         | 
| 52 56 | 
             
                  end
         | 
| 53 57 |  | 
| 54 58 | 
             
                  private
         | 
| 55 59 |  | 
| 60 | 
            +
                  def fetch_all_experiment_ids
         | 
| 61 | 
            +
                    response = redis.get(ALL_EXPERIMENTS_KEYS_KEY)
         | 
| 62 | 
            +
                    if response
         | 
| 63 | 
            +
                      JSON.parse(response)
         | 
| 64 | 
            +
                    else
         | 
| 65 | 
            +
                      []
         | 
| 66 | 
            +
                    end
         | 
| 67 | 
            +
                  end
         | 
| 68 | 
            +
             | 
| 56 69 | 
             
                  def redis_key(experiment_id:)
         | 
| 57 70 | 
             
                    "laboratory_#{experiment_id}"
         | 
| 58 71 | 
             
                  end
         | 
| @@ -79,9 +92,11 @@ module Laboratory | |
| 79 92 |  | 
| 80 93 | 
             
                  def experiment_events_to_hash(events)
         | 
| 81 94 | 
             
                    events.map do |event|
         | 
| 95 | 
            +
                      event_recordings =
         | 
| 96 | 
            +
                        experiment_event_recordings_to_hash(event.event_recordings)
         | 
| 82 97 | 
             
                      {
         | 
| 83 98 | 
             
                        id: event.id,
         | 
| 84 | 
            -
                        event_recordings:  | 
| 99 | 
            +
                        event_recordings: event_recordings
         | 
| 85 100 | 
             
                      }
         | 
| 86 101 | 
             
                    end
         | 
| 87 102 | 
             
                  end
         | 
| @@ -127,9 +142,13 @@ module Laboratory | |
| 127 142 |  | 
| 128 143 | 
             
                  def parse_json_to_experiment_events(events_json)
         | 
| 129 144 | 
             
                    events_json.map do |json|
         | 
| 145 | 
            +
                      event_recordings = parse_json_to_experiment_event_recordings(
         | 
| 146 | 
            +
                        json['event_recordings']
         | 
| 147 | 
            +
                      )
         | 
| 148 | 
            +
             | 
| 130 149 | 
             
                      Experiment::Event.new(
         | 
| 131 150 | 
             
                        id: json['id'],
         | 
| 132 | 
            -
                        event_recordings:  | 
| 151 | 
            +
                        event_recordings: event_recordings
         | 
| 133 152 | 
             
                      )
         | 
| 134 153 | 
             
                    end
         | 
| 135 154 | 
             
                  end
         | 
| @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            module Laboratory
         | 
| 2 2 | 
             
              module Calculations
         | 
| 3 3 | 
             
                module ConfidenceLevel
         | 
| 4 | 
            -
                  def self.calculate(n1:, p1:, n2:, p2:)
         | 
| 4 | 
            +
                  def self.calculate(n1:, p1:, n2:, p2:) # rubocop:disable Naming/MethodParameterName
         | 
| 5 5 | 
             
                    cvr1 = p1.fdiv(n1)
         | 
| 6 6 | 
             
                    cvr2 = p2.fdiv(n2)
         | 
| 7 7 |  | 
| @@ -15,7 +15,7 @@ module Laboratory | |
| 15 15 | 
             
                    percentage_from_z_score(-z).round(4)
         | 
| 16 16 | 
             
                  end
         | 
| 17 17 |  | 
| 18 | 
            -
                  def self.percentage_from_z_score(z)
         | 
| 18 | 
            +
                  def self.percentage_from_z_score(z) # rubocop:disable Naming/MethodParameterName, Metrics/AbcSize, Metrics/MethodLength
         | 
| 19 19 | 
             
                    return 0 if z < -6.5
         | 
| 20 20 | 
             
                    return 1 if z > 6.5
         | 
| 21 21 |  | 
| @@ -23,10 +23,13 @@ module Laboratory | |
| 23 23 | 
             
                    sum = 0
         | 
| 24 24 | 
             
                    term = 1
         | 
| 25 25 | 
             
                    k = 0
         | 
| 26 | 
            +
                    const = 0.3989422804
         | 
| 26 27 |  | 
| 27 28 | 
             
                    loop_stop = Math.exp(-23)
         | 
| 28 | 
            -
                    while term.abs > loop_stop do
         | 
| 29 | 
            -
                      term = | 
| 29 | 
            +
                    while term.abs > loop_stop do # rubocop:disable Style/WhileUntilDo
         | 
| 30 | 
            +
                      term =
         | 
| 31 | 
            +
                        const * ((-1)**k) * (z**k) / (2 * k + 1) / (2**k) * (z**(k + 1)) / factk # rubocop:disable Layout/LineLength
         | 
| 32 | 
            +
             | 
| 30 33 | 
             
                      sum += term
         | 
| 31 34 | 
             
                      k += 1
         | 
| 32 35 | 
             
                      factk *= k
         | 
| @@ -6,33 +6,35 @@ module Laboratory | |
| 6 6 | 
             
                  # n: Total population
         | 
| 7 7 | 
             
                  # p: conversion percentage
         | 
| 8 8 |  | 
| 9 | 
            -
                  def self.calculate(n1:, p1:, n2:, p2:)
         | 
| 10 | 
            -
                     | 
| 11 | 
            -
                     | 
| 9 | 
            +
                  def self.calculate(n1:, p1:, n2:, p2:) # rubocop:disable Metrics/AbcSize, Naming/MethodParameterName, Metrics/MethodLength
         | 
| 10 | 
            +
                    p1_float = p1.to_f
         | 
| 11 | 
            +
                    p2_float = p2.to_f
         | 
| 12 12 |  | 
| 13 | 
            -
                     | 
| 14 | 
            -
                     | 
| 13 | 
            +
                    n1_float = n1.to_f
         | 
| 14 | 
            +
                    n2_float = n2.to_f
         | 
| 15 15 |  | 
| 16 16 | 
             
                    # Formula for standard error: root(pq/n) = root(p(1-p)/n)
         | 
| 17 | 
            -
                     | 
| 18 | 
            -
                     | 
| 17 | 
            +
                    s1_float = Math.sqrt(p1_float * (1 - p1_float) / n1_float)
         | 
| 18 | 
            +
                    s2_float = Math.sqrt(p2_float * (1 - p2_float) / n2_float)
         | 
| 19 19 |  | 
| 20 | 
            -
                    # Formula for pooled error of the difference of the means: | 
| 21 | 
            -
                    #  | 
| 22 | 
            -
                    pi = ( | 
| 23 | 
            -
                     | 
| 20 | 
            +
                    # Formula for pooled error of the difference of the means:
         | 
| 21 | 
            +
                    # root(pi*(1-pi)*(1/na+1/nc)
         | 
| 22 | 
            +
                    # pi = (xa + xc) / (na + nc)
         | 
| 23 | 
            +
                    pi = (p2_float * n2_float + p1_float * n1_float) / (n2_float + n1_float)
         | 
| 24 | 
            +
                    s_p = Math.sqrt(pi * (1 - pi) * (1 / n2_float + 1 / n1_float))
         | 
| 24 25 |  | 
| 25 | 
            -
                    # Formula for unpooled error of the difference of the means: | 
| 26 | 
            -
                     | 
| 26 | 
            +
                    # Formula for unpooled error of the difference of the means:
         | 
| 27 | 
            +
                    # root(sa**2/pi*a + sc**2/nc)
         | 
| 28 | 
            +
                    s_unp = Math.sqrt(s2_float**2 + s1_float**2)
         | 
| 27 29 |  | 
| 28 30 | 
             
                    # Boolean variable decides whether we can pool our variances
         | 
| 29 | 
            -
                    pooled =  | 
| 31 | 
            +
                    pooled = s2_float / s1_float < 2 && s1_float / s2_float < 2
         | 
| 30 32 |  | 
| 31 33 | 
             
                    # Assign standard error either the pooled or unpooled variance
         | 
| 32 34 | 
             
                    se = pooled ? s_p : s_unp
         | 
| 33 35 |  | 
| 34 36 | 
             
                    # Calculate z-score
         | 
| 35 | 
            -
                    z_score = ( | 
| 37 | 
            +
                    z_score = (p2_float - p1_float) / se
         | 
| 36 38 |  | 
| 37 39 | 
             
                    z_score.round(4)
         | 
| 38 40 | 
             
                  end
         | 
| @@ -17,8 +17,9 @@ module Laboratory | |
| 17 17 | 
             
                  end
         | 
| 18 18 |  | 
| 19 19 | 
             
                  def performance_delta_between_highest_and_lowest
         | 
| 20 | 
            -
                    numerator = | 
| 21 | 
            -
                      conversion_rate_for_variant( | 
| 20 | 
            +
                    numerator =
         | 
| 21 | 
            +
                      (conversion_rate_for_variant(highest_performing_variant) -
         | 
| 22 | 
            +
                        conversion_rate_for_variant(lowest_performing_variant))
         | 
| 22 23 | 
             
                    denominator = conversion_rate_for_variant(lowest_performing_variant)
         | 
| 23 24 | 
             
                    numerator.fdiv(denominator).round(2)
         | 
| 24 25 | 
             
                  end
         | 
| @@ -57,9 +58,10 @@ module Laboratory | |
| 57 58 | 
             
                  end
         | 
| 58 59 |  | 
| 59 60 | 
             
                  def sorted_variants
         | 
| 60 | 
            -
                    relevant_variants.sort_by  | 
| 61 | 
            -
                       | 
| 62 | 
            -
             | 
| 61 | 
            +
                    relevant_variants.sort_by do |variant|
         | 
| 62 | 
            +
                      # Order in descending order
         | 
| 63 | 
            +
                      -1 * conversion_rate_for_variant(variant)
         | 
| 64 | 
            +
                    end
         | 
| 63 65 | 
             
                  end
         | 
| 64 66 |  | 
| 65 67 | 
             
                  def event_for_variant(variant)
         | 
| @@ -26,7 +26,11 @@ module Laboratory | |
| 26 26 | 
             
                  def changeset
         | 
| 27 27 | 
             
                    set = {}
         | 
| 28 28 | 
             
                    set[:id] = [_original_id, id] if _original_id != id
         | 
| 29 | 
            -
             | 
| 29 | 
            +
             | 
| 30 | 
            +
                    if _original_percentage != percentage
         | 
| 31 | 
            +
                      set[:percentage] = [_original_percentage, percentage]
         | 
| 32 | 
            +
                    end
         | 
| 33 | 
            +
             | 
| 30 34 | 
             
                    set
         | 
| 31 35 | 
             
                  end
         | 
| 32 36 | 
             
                end
         | 
| @@ -1,5 +1,19 @@ | |
| 1 1 | 
             
            module Laboratory
         | 
| 2 | 
            -
              class Experiment
         | 
| 2 | 
            +
              class Experiment # rubocop:disable Metrics/ClassLength
         | 
| 3 | 
            +
                class << self
         | 
| 4 | 
            +
                  def overrides
         | 
| 5 | 
            +
                    @overrides || {}
         | 
| 6 | 
            +
                  end
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  def override!(overrides)
         | 
| 9 | 
            +
                    @overrides = overrides
         | 
| 10 | 
            +
                  end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  def clear_overrides!
         | 
| 13 | 
            +
                    @overrides = {}
         | 
| 14 | 
            +
                  end
         | 
| 15 | 
            +
                end
         | 
| 16 | 
            +
             | 
| 3 17 | 
             
                class UserNotInExperimentError < StandardError; end
         | 
| 4 18 | 
             
                class ClashingExperimentIdError < StandardError; end
         | 
| 5 19 | 
             
                class MissingExperimentIdError < StandardError; end
         | 
| @@ -15,7 +29,7 @@ module Laboratory | |
| 15 29 | 
             
                  :changelog
         | 
| 16 30 | 
             
                )
         | 
| 17 31 |  | 
| 18 | 
            -
                def initialize(id:, variants:, algorithm: Algorithms::Random, changelog: [])
         | 
| 32 | 
            +
                def initialize(id:, variants:, algorithm: Algorithms::Random, changelog: []) # rubocop:disable Metrics/MethodLength
         | 
| 19 33 | 
             
                  @id = id
         | 
| 20 34 | 
             
                  @algorithm = algorithm
         | 
| 21 35 | 
             
                  @changelog = changelog
         | 
| @@ -24,7 +38,7 @@ module Laboratory | |
| 24 38 | 
             
                  # This also helps when decoding from adapters
         | 
| 25 39 |  | 
| 26 40 | 
             
                  @variants =
         | 
| 27 | 
            -
                    if variants.all? { |variant| variant.instance_of?( | 
| 41 | 
            +
                    if variants.all? { |variant| variant.instance_of?(Experiment::Variant) }
         | 
| 28 42 | 
             
                      variants
         | 
| 29 43 | 
             
                    elsif variants.all? { |variant| variant.instance_of?(Hash) }
         | 
| 30 44 | 
             
                      variants.map do |variant|
         | 
| @@ -83,9 +97,15 @@ module Laboratory | |
| 83 97 | 
             
                  save
         | 
| 84 98 | 
             
                end
         | 
| 85 99 |  | 
| 86 | 
            -
                def variant(user: Laboratory.config.current_user)
         | 
| 87 | 
            -
                   | 
| 88 | 
            -
             | 
| 100 | 
            +
                def variant(user: Laboratory.config.current_user) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
         | 
| 101 | 
            +
                  return variant_overridden_with if overridden?
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                  selected_variant =
         | 
| 104 | 
            +
                    variants.find do |variant|
         | 
| 105 | 
            +
                      variant.participant_ids.include?(user.id)
         | 
| 106 | 
            +
                    end
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                  return selected_variant unless selected_variant.nil?
         | 
| 89 109 |  | 
| 90 110 | 
             
                  variant = algorithm.pick!(variants)
         | 
| 91 111 | 
             
                  variant.add_participant(user)
         | 
| @@ -110,7 +130,7 @@ module Laboratory | |
| 110 130 | 
             
                  variant
         | 
| 111 131 | 
             
                end
         | 
| 112 132 |  | 
| 113 | 
            -
                def record_event!(event_id, user: Laboratory.config.current_user)
         | 
| 133 | 
            +
                def record_event!(event_id, user: Laboratory.config.current_user) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
         | 
| 114 134 | 
             
                  variant = variants.find { |s| s.participant_ids.include?(user.id) }
         | 
| 115 135 | 
             
                  raise UserNotInExperimentError unless variant
         | 
| 116 136 |  | 
| @@ -139,6 +159,7 @@ module Laboratory | |
| 139 159 |  | 
| 140 160 | 
             
                def save
         | 
| 141 161 | 
             
                  raise errors.first unless valid?
         | 
| 162 | 
            +
             | 
| 142 163 | 
             
                  unless changeset.empty?
         | 
| 143 164 | 
             
                    changelog_item = Laboratory::Experiment::ChangelogItem.new(
         | 
| 144 165 | 
             
                      changes: changeset,
         | 
| @@ -151,7 +172,7 @@ module Laboratory | |
| 151 172 | 
             
                  Laboratory.config.adapter.write(self)
         | 
| 152 173 | 
             
                end
         | 
| 153 174 |  | 
| 154 | 
            -
                def valid?
         | 
| 175 | 
            +
                def valid? # rubocop:disable Metrics/AbcSize
         | 
| 155 176 | 
             
                  valid_variants =
         | 
| 156 177 | 
             
                    variants.all? do |variant|
         | 
| 157 178 | 
             
                      !variant.id.nil? && !variant.percentage.nil?
         | 
| @@ -165,14 +186,27 @@ module Laboratory | |
| 165 186 |  | 
| 166 187 | 
             
                private
         | 
| 167 188 |  | 
| 168 | 
            -
                def  | 
| 189 | 
            +
                def overridden?
         | 
| 190 | 
            +
                  self.class.overrides.key?(id) && !variant_overridden_with.nil?
         | 
| 191 | 
            +
                end
         | 
| 192 | 
            +
             | 
| 193 | 
            +
                def variant_overridden_with
         | 
| 194 | 
            +
                  variants.find { |v| v.id == self.class.overrides[id] }
         | 
| 195 | 
            +
                end
         | 
| 196 | 
            +
             | 
| 197 | 
            +
                def changeset # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
         | 
| 169 198 | 
             
                  set = {}
         | 
| 170 199 | 
             
                  set[:id] = [_original_id, id] if _original_id != id
         | 
| 171 | 
            -
                  set[:algorithm] = [_original_algorithm, algorithm] if _original_algorithm != algorithm
         | 
| 172 200 |  | 
| 173 | 
            -
                   | 
| 174 | 
            -
                     | 
| 175 | 
            -
                   | 
| 201 | 
            +
                  if _original_algorithm != algorithm
         | 
| 202 | 
            +
                    set[:algorithm] = [_original_algorithm, algorithm]
         | 
| 203 | 
            +
                  end
         | 
| 204 | 
            +
             | 
| 205 | 
            +
                  variants_changeset =
         | 
| 206 | 
            +
                    variants.map do |variant|
         | 
| 207 | 
            +
                      { variant.id => variant.changeset }
         | 
| 208 | 
            +
                    end
         | 
| 209 | 
            +
             | 
| 176 210 | 
             
                  variants_changeset.reject! do |change|
         | 
| 177 211 | 
             
                    change.values.all?(&:empty?)
         | 
| 178 212 | 
             
                  end
         | 
| @@ -181,23 +215,25 @@ module Laboratory | |
| 181 215 | 
             
                  set
         | 
| 182 216 | 
             
                end
         | 
| 183 217 |  | 
| 184 | 
            -
                def errors
         | 
| 218 | 
            +
                def errors # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
         | 
| 185 219 | 
             
                  errors = []
         | 
| 186 220 |  | 
| 187 | 
            -
                  missing_variant_ids = | 
| 188 | 
            -
                     | 
| 189 | 
            -
             | 
| 221 | 
            +
                  missing_variant_ids =
         | 
| 222 | 
            +
                    variants.any? do |variant|
         | 
| 223 | 
            +
                      variant.id.nil?
         | 
| 224 | 
            +
                    end
         | 
| 190 225 |  | 
| 191 | 
            -
                  missing_variant_percentages = | 
| 192 | 
            -
                     | 
| 193 | 
            -
             | 
| 226 | 
            +
                  missing_variant_percentages =
         | 
| 227 | 
            +
                    variants.any? do |variant|
         | 
| 228 | 
            +
                      variant.percentage.nil?
         | 
| 229 | 
            +
                    end
         | 
| 194 230 |  | 
| 195 231 | 
             
                  incorrect_percentage_total = variants.map(&:percentage).sum != 100
         | 
| 196 232 |  | 
| 197 233 | 
             
                  errors << MissingExperimentIdError if id.nil?
         | 
| 198 234 | 
             
                  errors << MissingExperimentAlgorithmError if algorithm.nil?
         | 
| 199 235 | 
             
                  errors << MissingExperimentVariantIdError if missing_variant_ids
         | 
| 200 | 
            -
                  errors << MissingExperimentVariantPercentageError if missing_variant_percentages
         | 
| 236 | 
            +
                  errors << MissingExperimentVariantPercentageError if missing_variant_percentages # rubocop:disable Layout/LineLength
         | 
| 201 237 | 
             
                  errors << IncorrectPercentageTotalError if incorrect_percentage_total
         | 
| 202 238 |  | 
| 203 239 | 
             
                  errors
         | 
| @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            module Laboratory
         | 
| 2 2 | 
             
              module UIHelpers
         | 
| 3 3 | 
             
                def url(*path_parts)
         | 
| 4 | 
            -
                  [ | 
| 4 | 
            +
                  [path_prefix, path_parts].join('/').squeeze('/')
         | 
| 5 5 | 
             
                end
         | 
| 6 6 |  | 
| 7 7 | 
             
                def path_prefix
         | 
| @@ -9,26 +9,31 @@ module Laboratory | |
| 9 9 | 
             
                end
         | 
| 10 10 |  | 
| 11 11 | 
             
                def experiment_url(experiment)
         | 
| 12 | 
            -
                  url('experiments', experiment.id, 'edit')
         | 
| 12 | 
            +
                  url('experiments', CGI.escape(experiment.id), 'edit')
         | 
| 13 13 | 
             
                end
         | 
| 14 14 |  | 
| 15 15 | 
             
                def update_percentages_url(experiment)
         | 
| 16 | 
            -
                  url('experiments', experiment.id, 'update_percentages')
         | 
| 16 | 
            +
                  url('experiments', CGI.escape(experiment.id), 'update_percentages')
         | 
| 17 17 | 
             
                end
         | 
| 18 18 |  | 
| 19 19 | 
             
                def assign_users_to_variant_url(experiment)
         | 
| 20 | 
            -
                  url('experiments', experiment.id, 'assign_users')
         | 
| 20 | 
            +
                  url('experiments', CGI.escape(experiment.id), 'assign_users')
         | 
| 21 21 | 
             
                end
         | 
| 22 22 |  | 
| 23 23 | 
             
                def reset_experiment_url(experiment)
         | 
| 24 | 
            -
                  url('experiments', experiment.id, 'reset')
         | 
| 24 | 
            +
                  url('experiments', CGI.escape(experiment.id), 'reset')
         | 
| 25 25 | 
             
                end
         | 
| 26 26 |  | 
| 27 27 | 
             
                def analysis_summary(experiment, event_id)
         | 
| 28 | 
            +
                  return if experiment.variants.length < 2
         | 
| 29 | 
            +
             | 
| 28 30 | 
             
                  analysis = experiment.analysis_summary_for(event_id)
         | 
| 29 | 
            -
                  return if analysis.highest_performing_variant == analysis.lowest_performing_variant
         | 
| 30 31 |  | 
| 31 | 
            -
                  "#{analysis.highest_performing_variant.id} is performing  | 
| 32 | 
            +
                  "#{analysis.highest_performing_variant.id} is performing" \
         | 
| 33 | 
            +
                  " #{analysis.performance_delta_between_highest_and_lowest * 100}%" \
         | 
| 34 | 
            +
                  " better than #{analysis.lowest_performing_variant.id}. I'm" \
         | 
| 35 | 
            +
                  " #{analysis.confidence_level_in_performance_delta * 100}% certain of" \
         | 
| 36 | 
            +
                  ' this.'
         | 
| 32 37 | 
             
                end
         | 
| 33 38 | 
             
              end
         | 
| 34 39 | 
             
            end
         | 
    
        data/lib/laboratory/ui.rb
    CHANGED
    
    | @@ -19,13 +19,13 @@ module Laboratory | |
| 19 19 | 
             
                end
         | 
| 20 20 |  | 
| 21 21 | 
             
                get '/experiments/:id/edit' do
         | 
| 22 | 
            -
                  @experiment = Laboratory::Experiment.find(params[:id])
         | 
| 22 | 
            +
                  @experiment = Laboratory::Experiment.find(CGI.unescape(params[:id]))
         | 
| 23 23 | 
             
                  erb :edit
         | 
| 24 24 | 
             
                end
         | 
| 25 25 |  | 
| 26 26 | 
             
                # params = {variants: { control => 40, variant_a => 60 }}
         | 
| 27 27 | 
             
                post '/experiments/:id/update_percentages' do
         | 
| 28 | 
            -
                  experiment = Laboratory::Experiment.find(params[:id])
         | 
| 28 | 
            +
                  experiment = Laboratory::Experiment.find(CGI.unescape(params[:id]))
         | 
| 29 29 |  | 
| 30 30 | 
             
                  params[:variants].each do |variant_id, percentage|
         | 
| 31 31 | 
             
                    variant = experiment.variants.find { |v| v.id == variant_id }
         | 
| @@ -38,7 +38,7 @@ module Laboratory | |
| 38 38 |  | 
| 39 39 | 
             
                # params = {variant_id: 'control', user_ids: []}
         | 
| 40 40 | 
             
                post '/experiments/:id/assign_users' do
         | 
| 41 | 
            -
                  experiment = Laboratory::Experiment.find(params[:id])
         | 
| 41 | 
            +
                  experiment = Laboratory::Experiment.find(CGI.unescape(params[:id]))
         | 
| 42 42 | 
             
                  variant = experiment.variants.find { |v| v.id == params[:variant_id] }
         | 
| 43 43 | 
             
                  user_ids = params[:user_ids].split("\r\n")
         | 
| 44 44 |  | 
| @@ -51,7 +51,7 @@ module Laboratory | |
| 51 51 | 
             
                end
         | 
| 52 52 |  | 
| 53 53 | 
             
                post '/experiments/:id/reset' do
         | 
| 54 | 
            -
                  experiment = Laboratory::Experiment.find(params[:id])
         | 
| 54 | 
            +
                  experiment = Laboratory::Experiment.find(CGI.unescape(params[:id]))
         | 
| 55 55 | 
             
                  experiment.reset
         | 
| 56 56 | 
             
                  redirect experiment_url(experiment)
         | 
| 57 57 | 
             
                end
         | 
    
        data/lib/laboratory/version.rb
    CHANGED