laboratory 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -1,6 +1,6 @@
1
- source "https://rubygems.org"
1
+ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in laboratory.gemspec
4
4
  gemspec
5
5
 
6
- gem "rake", "~> 12.0"
6
+ gem 'rake', '~> 12.0'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- laboratory (0.1.1)
4
+ laboratory (0.1.2)
5
5
  redis (>= 2.1)
6
6
  sinatra (>= 1.2.6)
7
7
 
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
+ ![ui-interface](https://imgur.com/l23iiet.png)
23
+
24
+ ### Editting an experiment
25
+
26
+ ![edit-ui-interface](https://imgur.com/O6JkDk0.png)
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
@@ -1,6 +1,6 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
3
 
4
4
  RSpec::Core::RakeTask.new(:spec)
5
5
 
6
- task :default => :spec
6
+ task default: :spec
data/bin/console CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require "bundler/setup"
4
- require "laboratory"
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 "pry"
10
+ # require 'pry'
11
11
  # Pry.start
12
12
 
13
- require "irb"
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 = 'Laboratory: An A/B Testing and Feature Flag system for Ruby'
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 into git.
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 { |f| f.match(%r{^(test|spec|features)/}) }
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 = 'SWITCH_ALL_EXPERIMENT_KEYS'.freeze
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
- redis.set(redis_key(experiment_id: experiment.id), experiment_to_json(experiment))
18
- # Write to ALL_EXPERIMENTS_KEY_KEY if it isn't already there.
19
- experiment_ids = JSON.parse(redis.get(ALL_EXPERIMENTS_KEYS_KEY))
20
- experiment_ids << experiment.id unless experiment_ids.include?(experiment.id)
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 = JSON.parse(redis.get(ALL_EXPERIMENTS_KEYS_KEY))
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 = JSON.parse(redis.get(ALL_EXPERIMENTS_KEYS_KEY))
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 = JSON.parse(redis.get(ALL_EXPERIMENTS_KEYS_KEY))
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: experiment_event_recordings_to_hash(event.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: parse_json_to_experiment_event_recordings(json['event_recordings'])
151
+ event_recordings: event_recordings
133
152
  )
134
153
  end
135
154
  end
@@ -2,7 +2,7 @@ module Laboratory
2
2
  module Algorithms
3
3
  class Random
4
4
  def self.pick!(variants)
5
- variants.sort_by { |variant| - variant.percentage * rand() }.first
5
+ variants.min_by { |variant| - variant.percentage * rand }
6
6
  end
7
7
 
8
8
  def self.id
@@ -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 = 0.3989422804 * ((-1)**k) * (z**k) / (2 * k + 1) / (2**k) * (z**(k + 1)) / factk
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
- p_1 = p1.to_f
11
- p_2 = p2.to_f
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
- n_1 = n1.to_f
14
- n_2 = n2.to_f
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
- s_1 = Math.sqrt(p_1 * (1 - p_1) / n_1)
18
- s_2 = Math.sqrt(p_2 * (1 - p_2) / n_2)
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: root(π*(1-π)*(1/na+1/nc)
21
- # π = (xa + xc) / (na + nc)
22
- pi = (p_2 * n_2 + p_1 * n_1) / (n_2 + n_1)
23
- s_p = Math.sqrt(pi * (1 - pi) * (1 / n_2 + 1 / n_1))
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: root(sa**2/na + sc**2/nc)
26
- s_unp = Math.sqrt(s_2**2 + s_1**2)
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 = s_2 / s_1 < 2 && s_1 / s_2 < 2
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 = (p_2 - p_1) / (se)
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 = (conversion_rate_for_variant(highest_performing_variant) -
21
- conversion_rate_for_variant(lowest_performing_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 { |variant|
61
- conversion_rate_for_variant(variant)
62
- }.reverse
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
- set[:percentage] = [_original_percentage, percentage] if _original_percentage != percentage
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?(Laboratory::Experiment::Variant) }
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
- selected_variant = variants.find { |variant| variant.participant_ids.include?(user.id)}
88
- return selected_variant if !selected_variant.nil?
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 changeset
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
- variants_changeset = variants.map { |variant|
174
- { variant.id => variant.changeset }
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 = variants.any? do |variant|
188
- variant.id.nil?
189
- end
221
+ missing_variant_ids =
222
+ variants.any? do |variant|
223
+ variant.id.nil?
224
+ end
190
225
 
191
- missing_variant_percentages = variants.any? do |variant|
192
- variant.percentage.nil?
193
- end
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
- [ path_prefix, path_parts ].join("/").squeeze('/')
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 #{analysis.performance_delta_between_highest_and_lowest * 100}% better than #{analysis.lowest_performing_variant.id}. I'm #{analysis.confidence_level_in_performance_delta * 100}% certain of this."
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
@@ -1,3 +1,3 @@
1
1
  module Laboratory
2
- VERSION = "0.1.1"
2
+ VERSION = '0.1.2'.freeze
3
3
  end