laboratory 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fddd4e5310fe374f6dce5c3662d4e171987a3aa1cd180fca0f4123890c74ff74
4
- data.tar.gz: 22957e0e5595b93d4005dac1329f87ccc78428576a1b4519e7634edcb405eac3
3
+ metadata.gz: '028eeacc314798c88a5a6b5b3db37754751dbe90e18907c130ff2f517685f09e'
4
+ data.tar.gz: 526145857a0786d662f06b8204c6854c5618f3150286eb19e8ae1e2022e1b17e
5
5
  SHA512:
6
- metadata.gz: 9ed204a1530ce008417f27407188a102982d77ec175f62fd0f117b9035c8f091f08700572798f9071ac2c352e5f199e8e6a53f114864005622e054beb69073a6
7
- data.tar.gz: b0fbec760e8b1187ed5917b4d9e7ddd936e9b7caebc2f89738ed4d09fe6688a9343394eb2fac7e18f13b6ef0821226f6cfc4d4e6be39ccf3d4e12afe628dfdfd
6
+ metadata.gz: 5b7942166dc0dca4cc5c900ddde6c98dc9734eb390c776e309a24598eb10b7e1f07f518d941b4b47aeea81928ffb925176bb199d804cdc5ad061661c8bb6e98f
7
+ data.tar.gz: f08de0e7e5d841cb6d6be5cc3be9de9c34f9f1f85c0173e95f76a786c4a2eefebb05322b3a2e6b65a4643dd5ae9c8ff7ead830e98573f38b5edb8cafca170365
data/Gemfile.lock CHANGED
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- laboratory (0.1.0)
4
+ laboratory (0.1.1)
5
5
  redis (>= 2.1)
6
+ sinatra (>= 1.2.6)
6
7
 
7
8
  GEM
8
9
  remote: https://rubygems.org/
@@ -10,6 +11,13 @@ GEM
10
11
  diff-lcs (1.3)
11
12
  fakeredis (0.8.0)
12
13
  redis (~> 4.1)
14
+ mustermann (1.1.1)
15
+ ruby2_keywords (~> 0.0.1)
16
+ rack (2.2.2)
17
+ rack-protection (2.0.8.1)
18
+ rack
19
+ rack-test (1.1.0)
20
+ rack (>= 1.0, < 3)
13
21
  rake (12.3.3)
14
22
  redis (4.1.3)
15
23
  rspec (3.9.0)
@@ -25,6 +33,13 @@ GEM
25
33
  diff-lcs (>= 1.2.0, < 2.0)
26
34
  rspec-support (~> 3.9.0)
27
35
  rspec-support (3.9.2)
36
+ ruby2_keywords (0.0.2)
37
+ sinatra (2.0.8.1)
38
+ mustermann (~> 1.0)
39
+ rack (~> 2.0)
40
+ rack-protection (= 2.0.8.1)
41
+ tilt (~> 2.0)
42
+ tilt (2.0.10)
28
43
 
29
44
  PLATFORMS
30
45
  ruby
@@ -32,6 +47,7 @@ PLATFORMS
32
47
  DEPENDENCIES
33
48
  fakeredis (~> 0.8)
34
49
  laboratory!
50
+ rack-test (~> 1.1)
35
51
  rake (~> 12.0)
36
52
  rspec (~> 3.8)
37
53
 
data/README.md CHANGED
@@ -11,12 +11,14 @@ Laboratory is an A/B testing and Feature Flag framework for Rails. It's focused
11
11
  * Simplicity, while not being simplisitic
12
12
  * Laboratory comes with a single A/B test algorithm and single storage adapter built-in, which we think will cover 99% of use cases for A/B testing and Feature Flagging. If you're in that 1%, it's easy to extend Laboratory to add custom Algorithms and Storage mechanisms.
13
13
 
14
+ Laboratory builds upon great work from other gems, in particular [Split](https://github.com/splitrb/split).
15
+
14
16
  ## Installation
15
17
 
16
18
  Add this line to your application's Gemfile:
17
19
 
18
20
  ```ruby
19
- gem 'laboratory'
21
+ gem 'laboratory', require: 'laboratory/ui'
20
22
  ```
21
23
 
22
24
  And then execute:
@@ -35,7 +37,7 @@ Or install it yourself as:
35
37
  In an initializer (`app/config/initializers/laboratory.rb`), define the adapter you are going to use with Laboratory. Laboratory supports Redis out of the box, as it the recommended adapter:
36
38
 
37
39
  ```ruby
38
- Laboratory::Config.adapter = Laboratory::Adapters::RedisAdapter.new(url: 'redis://localhost:6789') # Adjust to your redis URL.
40
+ Laboratory.config.adapter = Laboratory::Adapters::RedisAdapter.new(url: 'redis://localhost:6789') # Adjust to your redis URL.
39
41
  ```
40
42
 
41
43
  ### Defining your current_user_id & actor
@@ -51,11 +53,11 @@ class ApplicationController
51
53
  before_action :set_laboratory_actor
52
54
 
53
55
  def set_laboratory_current_user_id
54
- Laboratory::Config.current_user_id = your_current_user_id
56
+ Laboratory.config.current_user_id = your_current_user_id
55
57
  end
56
58
 
57
59
  def set_laboratory_actor
58
- Laboratory::Config.actor = 'Tom Jones'
60
+ Laboratory.config.actor = 'Tom Jones'
59
61
  end
60
62
  ```
61
63
 
@@ -144,7 +146,7 @@ It's common to trigger analytics events upon common actions like a user being as
144
146
  **When a user gets assigned to a variant**:
145
147
 
146
148
  ```ruby
147
- Laboratory::Config.on_assignment_to_variant = -> (experiment, variant, user) {
149
+ Laboratory.config.on_assignment_to_variant = -> (experiment, variant, user) {
148
150
  ...
149
151
  }
150
152
  ```
@@ -152,7 +154,7 @@ Laboratory::Config.on_assignment_to_variant = -> (experiment, variant, user) {
152
154
  **When an event is recorded**:
153
155
 
154
156
  ```ruby
155
- Laboratory::Config.on_event_recorded = -> (experiment, variant, user, event) {
157
+ Laboratory.config.on_event_recorded = -> (experiment, variant, user, event) {
156
158
  ...
157
159
  }
158
160
  ```
@@ -210,18 +212,13 @@ Note: This would wipe all users from the experiment.
210
212
 
211
213
  ```ruby
212
214
  experiment = Laboratory::Experiment.find('blue_button_cta')
213
- variants = [
214
- {
215
- id: 'control',
216
- percentage: 40
217
- },
218
- {
219
- id: 'variant_a',
220
- percentage: 60
221
- }
222
- ]
223
-
224
- experiment.update(variants: new_variants)
215
+ control = experiment.variants.find { |variant| variant.id == 'control' }
216
+ variant_a = experiment.variants.find { |variant| variant.id == 'variant_a' }
217
+
218
+ control.percentage = 30
219
+ variant_a.percentage = 70
220
+
221
+ experiment.save
225
222
  ```
226
223
 
227
224
  **Finding the current user**:
@@ -229,7 +226,7 @@ experiment.update(variants: new_variants)
229
226
  The following will return a Laboratory::User object with the user id matching the current_user_id defined in the Laboratory configuration.
230
227
 
231
228
  ```ruby
232
- Laboratory::Config.current_user
229
+ Laboratory.config.current_user
233
230
  ```
234
231
 
235
232
 
data/laboratory.gemspec CHANGED
@@ -26,7 +26,9 @@ Gem::Specification.new do |spec|
26
26
  spec.require_paths = ['lib']
27
27
 
28
28
  spec.add_dependency 'redis', '>= 2.1'
29
+ spec.add_dependency 'sinatra', '>= 1.2.6'
29
30
 
31
+ spec.add_development_dependency 'rack-test', '~> 1.1'
30
32
  spec.add_development_dependency 'rspec', '~> 3.8'
31
33
  spec.add_development_dependency 'fakeredis', '~> 0.8'
32
34
  end
@@ -15,7 +15,6 @@ module Laboratory
15
15
 
16
16
  def write(experiment)
17
17
  redis.set(redis_key(experiment_id: experiment.id), experiment_to_json(experiment))
18
-
19
18
  # Write to ALL_EXPERIMENTS_KEY_KEY if it isn't already there.
20
19
  experiment_ids = JSON.parse(redis.get(ALL_EXPERIMENTS_KEYS_KEY))
21
20
  experiment_ids << experiment.id unless experiment_ids.include?(experiment.id)
@@ -118,11 +117,10 @@ module Laboratory
118
117
 
119
118
  def parse_json_to_experiment_changelog_items(changelog_json)
120
119
  changelog_json.map do |json|
121
- Experiment::ChangelogItems.new(
122
- action: json[:action],
123
- changes: json[:changes],
124
- timestamp: json[:timestamp],
125
- actor: json[:actor]
120
+ Experiment::ChangelogItem.new(
121
+ changes: json['changes'],
122
+ timestamp: json['timestamp'],
123
+ actor: json['actor']
126
124
  )
127
125
  end
128
126
  end
@@ -0,0 +1,40 @@
1
+ module Laboratory
2
+ module Calculations
3
+ module ConfidenceLevel
4
+ def self.calculate(n1:, p1:, n2:, p2:)
5
+ cvr1 = p1.fdiv(n1)
6
+ cvr2 = p2.fdiv(n2)
7
+
8
+ z = ZScore.calculate(
9
+ n1: n1,
10
+ p1: cvr1,
11
+ n2: n2,
12
+ p2: cvr2
13
+ )
14
+
15
+ percentage_from_z_score(-z).round(4)
16
+ end
17
+
18
+ def self.percentage_from_z_score(z)
19
+ return 0 if z < -6.5
20
+ return 1 if z > 6.5
21
+
22
+ factk = 1
23
+ sum = 0
24
+ term = 1
25
+ k = 0
26
+
27
+ 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
30
+ sum += term
31
+ k += 1
32
+ factk *= k
33
+ end
34
+
35
+ sum += 0.5
36
+ 1 - sum
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,41 @@
1
+ module Laboratory
2
+ module Calculations
3
+ module ZScore
4
+ include Math
5
+
6
+ # n: Total population
7
+ # p: conversion percentage
8
+
9
+ def self.calculate(n1:, p1:, n2:, p2:)
10
+ p_1 = p1.to_f
11
+ p_2 = p2.to_f
12
+
13
+ n_1 = n1.to_f
14
+ n_2 = n2.to_f
15
+
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)
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))
24
+
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)
27
+
28
+ # Boolean variable decides whether we can pool our variances
29
+ pooled = s_2 / s_1 < 2 && s_1 / s_2 < 2
30
+
31
+ # Assign standard error either the pooled or unpooled variance
32
+ se = pooled ? s_p : s_unp
33
+
34
+ # Calculate z-score
35
+ z_score = (p_2 - p_1) / (se)
36
+
37
+ z_score.round(4)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,72 @@
1
+ module Laboratory
2
+ class Experiment
3
+ class AnalysisSummary
4
+ attr_reader :experiment, :event_id
5
+
6
+ def initialize(experiment, event_id)
7
+ @experiment = experiment
8
+ @event_id = event_id
9
+ end
10
+
11
+ def highest_performing_variant
12
+ sorted_variants.first
13
+ end
14
+
15
+ def lowest_performing_variant
16
+ sorted_variants.last
17
+ end
18
+
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))
22
+ denominator = conversion_rate_for_variant(lowest_performing_variant)
23
+ numerator.fdiv(denominator).round(2)
24
+ end
25
+
26
+ def confidence_level_in_performance_delta
27
+ Laboratory::Calculations::ConfidenceLevel.calculate(
28
+ n1: participant_count_for_variant(lowest_performing_variant),
29
+ p1: event_total_count_for_variant(lowest_performing_variant),
30
+ n2: participant_count_for_variant(highest_performing_variant),
31
+ p2: event_total_count_for_variant(highest_performing_variant)
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def participant_count_for_variant(variant)
38
+ variant.participant_ids.count
39
+ end
40
+
41
+ def event_total_count_for_variant(variant)
42
+ event = event_for_variant(variant)
43
+ event.event_recordings.count
44
+ end
45
+
46
+ def conversion_rate_for_variant(variant)
47
+ event_total_count_for_variant(variant)
48
+ .fdiv(participant_count_for_variant(variant))
49
+ end
50
+
51
+ def relevant_variants
52
+ experiment.variants.select do |variant|
53
+ variant.events.any? do |event|
54
+ event.id == event_id
55
+ end
56
+ end
57
+ end
58
+
59
+ def sorted_variants
60
+ relevant_variants.sort_by { |variant|
61
+ conversion_rate_for_variant(variant)
62
+ }.reverse
63
+ end
64
+
65
+ def event_for_variant(variant)
66
+ variant.events.find do |event|
67
+ event.id == event_id
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,10 +1,9 @@
1
1
  module Laboratory
2
2
  class Experiment
3
3
  class ChangelogItem
4
- attr_reader :action, :changes, :timestamp, :actor
4
+ attr_reader :changes, :timestamp, :actor
5
5
 
6
- def initialize(action:, changes: [], timestamp:, actor:)
7
- @action = action
6
+ def initialize(changes:, timestamp:, actor:)
8
7
  @changes = changes
9
8
  @timestamp = timestamp
10
9
  @actor = actor
@@ -1,18 +1,34 @@
1
1
  module Laboratory
2
2
  class Experiment
3
3
  class Variant
4
- attr_reader :id, :percentage, :participant_ids, :events
4
+ attr_accessor :id, :percentage
5
+ attr_reader(
6
+ :_original_id,
7
+ :_original_percentage,
8
+ :participant_ids,
9
+ :events
10
+ )
5
11
 
6
12
  def initialize(id:, percentage:, participant_ids: [], events: [])
7
13
  @id = id
8
14
  @percentage = percentage
9
15
  @participant_ids = participant_ids
10
16
  @events = events
17
+
18
+ @_original_id = id
19
+ @_original_percentage = percentage
11
20
  end
12
21
 
13
22
  def add_participant(user)
14
23
  participant_ids << user.id
15
24
  end
25
+
26
+ def changeset
27
+ set = {}
28
+ set[:id] = [_original_id, id] if _original_id != id
29
+ set[:percentage] = [_original_percentage, percentage] if _original_percentage != percentage
30
+ set
31
+ end
16
32
  end
17
33
  end
18
34
  end
@@ -7,7 +7,13 @@ module Laboratory
7
7
  class InvalidExperimentVariantFormatError < StandardError; end
8
8
  class IncorrectPercentageTotalError < StandardError; end
9
9
 
10
- attr_reader :id, :variants, :algorithm, :changelog
10
+ attr_accessor :id, :algorithm
11
+ attr_reader(
12
+ :_original_id,
13
+ :_original_algorithm,
14
+ :variants,
15
+ :changelog
16
+ )
11
17
 
12
18
  def initialize(id:, variants:, algorithm: Algorithms::Random, changelog: [])
13
19
  @id = id
@@ -30,6 +36,9 @@ module Laboratory
30
36
  )
31
37
  end
32
38
  end
39
+
40
+ @_original_id = id
41
+ @_original_algorithm = algorithm
33
42
  end
34
43
 
35
44
  def self.all
@@ -39,21 +48,13 @@ module Laboratory
39
48
  def self.create(id:, variants:, algorithm: Algorithms::Random)
40
49
  raise ClashingExperimentIdError if find(id)
41
50
 
42
- changelog_item = Laboratory::Experiment::ChangelogItem.new(
43
- action: :create,
44
- changes: [],
45
- timestamp: Time.now,
46
- actor: Laboratory::Config.actor
47
- )
48
-
49
51
  experiment = Experiment.new(
50
52
  id: id,
51
53
  variants: variants,
52
- algorithm: algorithm,
53
- changelog: [changelog_item]
54
+ algorithm: algorithm
54
55
  )
55
56
 
56
- experiment.write!
57
+ experiment.save
57
58
  experiment
58
59
  end
59
60
 
@@ -65,62 +66,23 @@ module Laboratory
65
66
  find(id) || create(id: id, variants: variants, algorithm: algorithm)
66
67
  end
67
68
 
68
- def update(attrs)
69
- # delete previous key if valid? passes below.
70
- old_id = id
71
-
72
- # Diff changes
73
-
74
- current_hash = {
75
- id: id,
76
- variants: variants.map { |variant|
77
- {
78
- id: variant.id,
79
- percentage: variant.percentage
80
- }
81
- },
82
- algorithm: algorithm
83
- }
84
-
85
- updated_variants_subhash = attrs[:variants]&.map do |variant|
86
- {
87
- id: variant[:id],
88
- percentage: variant[:percentage]
89
- }
90
- end
91
-
92
- updated_hash = {
93
- id: attrs[:id] || id,
94
- variants: updated_variants_subhash || current_hash[:variants],
95
- algorithm: attrs[:algorithm] || algorithm
96
- }
97
-
98
- changes = current_hash.to_a - updated_hash.to_a
99
- @id = attrs[:id] if !attrs[:id].nil?
100
- @variants = attrs[:variants] if !attrs[:variants].nil?
101
- @algorithm = attrs[:algorithm] if !attrs[:algorithm].nil?
102
-
103
- raise errors.first unless valid?
104
-
105
- changelog_item = Laboratory::Experiment::ChangelogItem.new(
106
- action: :update,
107
- changes: changes,
108
- timestamp: Time.now,
109
- actor: Laboratory.config.actor
110
- )
111
-
112
- @changelog << changelog_item
113
-
114
- Laboratory.config.adapter.delete(old_id)
115
- write!
116
- self
117
- end
118
-
119
69
  def delete
120
70
  Laboratory.config.adapter.delete(id)
121
71
  nil
122
72
  end
123
73
 
74
+ def reset
75
+ @variants = variants.map do |variant|
76
+ Variant.new(
77
+ id: variant.id,
78
+ percentage: variant.percentage,
79
+ participant_ids: [],
80
+ events: []
81
+ )
82
+ end
83
+ save
84
+ end
85
+
124
86
  def variant(user: Laboratory.config.current_user)
125
87
  selected_variant = variants.find { |variant| variant.participant_ids.include?(user.id)}
126
88
  return selected_variant if !selected_variant.nil?
@@ -130,7 +92,7 @@ module Laboratory
130
92
 
131
93
  Laboratory::Config.on_assignment_to_variant&.call(self, variant, user)
132
94
 
133
- write!
95
+ save
134
96
  variant
135
97
  end
136
98
 
@@ -144,7 +106,7 @@ module Laboratory
144
106
 
145
107
  Laboratory::Config.on_assignment_to_variant&.call(self, variant, user)
146
108
 
147
- write!
109
+ save
148
110
  variant
149
111
  end
150
112
 
@@ -154,7 +116,7 @@ module Laboratory
154
116
 
155
117
  maybe_event = variant.events.find { |event| event.id == event_id }
156
118
  event =
157
- if maybe_event != nil
119
+ if !maybe_event.nil?
158
120
  maybe_event
159
121
  else
160
122
  e = Event.new(id: event_id)
@@ -167,12 +129,25 @@ module Laboratory
167
129
 
168
130
  Laboratory::Config.on_event_recorded&.call(self, variant, user, event)
169
131
 
170
- write!
132
+ save
171
133
  event_recording
172
134
  end
173
135
 
174
- def write!
136
+ def analysis_summary_for(event_id)
137
+ Experiment::AnalysisSummary.new(self, event_id)
138
+ end
139
+
140
+ def save
175
141
  raise errors.first unless valid?
142
+ unless changeset.empty?
143
+ changelog_item = Laboratory::Experiment::ChangelogItem.new(
144
+ changes: changeset,
145
+ timestamp: Time.now,
146
+ actor: Laboratory::Config.actor
147
+ )
148
+
149
+ @changelog << changelog_item
150
+ end
176
151
  Laboratory.config.adapter.write(self)
177
152
  end
178
153
 
@@ -188,6 +163,24 @@ module Laboratory
188
163
  !id.nil? && !algorithm.nil? && valid_variants && valid_percentage_amounts
189
164
  end
190
165
 
166
+ private
167
+
168
+ def changeset
169
+ set = {}
170
+ set[:id] = [_original_id, id] if _original_id != id
171
+ set[:algorithm] = [_original_algorithm, algorithm] if _original_algorithm != algorithm
172
+
173
+ variants_changeset = variants.map { |variant|
174
+ { variant.id => variant.changeset }
175
+ }
176
+ variants_changeset.reject! do |change|
177
+ change.values.all?(&:empty?)
178
+ end
179
+
180
+ set[:variants] = variants_changeset unless variants_changeset.empty?
181
+ set
182
+ end
183
+
191
184
  def errors
192
185
  errors = []
193
186
 
@@ -0,0 +1,34 @@
1
+ module Laboratory
2
+ module UIHelpers
3
+ def url(*path_parts)
4
+ [ path_prefix, path_parts ].join("/").squeeze('/')
5
+ end
6
+
7
+ def path_prefix
8
+ request.env['SCRIPT_NAME']
9
+ end
10
+
11
+ def experiment_url(experiment)
12
+ url('experiments', experiment.id, 'edit')
13
+ end
14
+
15
+ def update_percentages_url(experiment)
16
+ url('experiments', experiment.id, 'update_percentages')
17
+ end
18
+
19
+ def assign_users_to_variant_url(experiment)
20
+ url('experiments', experiment.id, 'assign_users')
21
+ end
22
+
23
+ def reset_experiment_url(experiment)
24
+ url('experiments', experiment.id, 'reset')
25
+ end
26
+
27
+ def analysis_summary(experiment, event_id)
28
+ analysis = experiment.analysis_summary_for(event_id)
29
+ return if analysis.highest_performing_variant == analysis.lowest_performing_variant
30
+
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
+ end
33
+ end
34
+ end
@@ -0,0 +1,48 @@
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
+ }
@@ -0,0 +1,102 @@
1
+ html, body {
2
+ height: 100%;
3
+ margin: 0;
4
+ }
5
+
6
+ body {
7
+ font-family: 'Verdana';
8
+ background-color: #EEEEEE;
9
+ color: #383B66;
10
+ line-height: 2rem;
11
+ }
12
+
13
+ h1 {
14
+ font-weight: bold;
15
+ font-size: 4rem;
16
+ }
17
+
18
+ h3 {
19
+ font-weight: bold;
20
+ font-size: 2rem;
21
+ }
22
+
23
+ h5 {
24
+ font-weight: bold;
25
+ font-size: 1rem;
26
+ }
27
+
28
+ .main {
29
+ min-height: 100%;
30
+ margin: 0 auto -30px;
31
+ }
32
+
33
+ .content {
34
+ padding: 40px;
35
+ }
36
+
37
+ .footer {
38
+ height: 30px;
39
+ padding-left: 40px;
40
+ }
41
+
42
+ .header {
43
+ margin-bottom: 2rem;
44
+ }
45
+
46
+ .experiment {
47
+ margin-bottom: 2rem;
48
+ padding: 1rem;
49
+ border: 0.1rem dashed #DDDDDD;
50
+ border-radius: 1rem;
51
+ }
52
+
53
+ .experiment-title {
54
+ }
55
+
56
+ .experiment-sections {
57
+ margin-top: 1rem;
58
+ }
59
+
60
+ .experiment-section:not(:last-child) {
61
+ margin-bottom: 1rem;
62
+ }
63
+
64
+ .experiment-section-title {
65
+ margin-bottom: 1rem;
66
+ }
67
+
68
+ .experiment-table {
69
+ display: flex;
70
+ flex-direction: column;
71
+ }
72
+
73
+ .experiment .row {
74
+ flex: 1;
75
+ display: flex;
76
+ flex-direction: row;
77
+ margin-bottom: 0.5rem;
78
+ }
79
+
80
+ .experiment .row.header {
81
+ padding-bottom: 0.3rem;
82
+ border-bottom: 0.05rem solid #CCCCCC
83
+ }
84
+
85
+ .experiment .row .column {
86
+ flex: 1;
87
+ }
88
+
89
+ .experiment .row .column:not(:last-child) {
90
+ flex-basis: 15rem;
91
+ flex-grow: 0;
92
+ }
93
+
94
+ .user-id-input-form {
95
+
96
+ }
97
+
98
+ .user-id-input-form label {
99
+ display: block
100
+ }
101
+
102
+
@@ -0,0 +1,86 @@
1
+ <% all_experiment_event_ids = experiment.variants.flat_map(&:events).map(&:id).uniq %>
2
+
3
+ <div class="experiment">
4
+ <h3 class='experiment-title'>Experiment: <%= experiment.id %></h3>
5
+ <div class='experiment-sections'>
6
+ <div class='experiment-section'>
7
+ <h5 class='experiment-section-title'>Summary</h5>
8
+ <div class='experiment-table'>
9
+ <div class="row header">
10
+ <div class='column'>
11
+ Variant
12
+ </div>
13
+ <div class='column'>
14
+ Percentage
15
+ </div>
16
+ <div class='column'>
17
+ Number of participants
18
+ </div>
19
+ </div>
20
+ <% experiment.variants.each do |variant| %>
21
+ <div class="row">
22
+ <div class='column'>
23
+ <%= variant.id %>
24
+ </div>
25
+ <div class='column'>
26
+ <%= variant.percentage %>%
27
+ </div>
28
+ <div class='column'>
29
+ <%= variant.participant_ids.count %>
30
+ </div>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+ </div>
35
+
36
+ <% all_experiment_event_ids.each do |event_id| %>
37
+ <div class='experiment-section'>
38
+ <h5 class='experiment-section-title'>Event: <%= event_id %></h5>
39
+ <p><%= analysis_summary(experiment, event_id) %>
40
+ <div class='experiment-table'>
41
+ <div class="row header">
42
+ <div class='column'>
43
+ Variant
44
+ </div>
45
+ <div class='column'>
46
+ Number of participants
47
+ </div>
48
+ <div class="column">
49
+ Number of event instances
50
+ </div>
51
+ <div class='column'>
52
+ Conversion Rate
53
+ </div>
54
+ </div>
55
+ <% experiment.variants.each do |variant| %>
56
+ <div class="row">
57
+ <div class='column'>
58
+ <%= variant.id %>
59
+ </div>
60
+ <div class='column'>
61
+ <%= variant.participant_ids.count %>
62
+ </div>
63
+ <% if variant.events.map(&:id).include?(event_id) %>
64
+ <% event = variant.events.find { |event| event.id == event_id } %>
65
+ <div class="column">
66
+ <%= event.event_recordings.count %>
67
+ </div>
68
+ <div class="column">
69
+ <%= ((event.event_recordings.count.fdiv(variant.participant_ids.count)) * 100).round(2) %>%
70
+ </div>
71
+ <% else %>
72
+ <div class='column'>
73
+ 0
74
+ </div>
75
+ <div class='column'>
76
+ N/A
77
+ </div>
78
+ <% end %>
79
+ </div>
80
+ <% end %>
81
+ </div>
82
+ </div>
83
+ <% end %>
84
+ </div>
85
+ <a href="<%= experiment_url(experiment) %>">Edit Experiment</a>
86
+ </div>
@@ -0,0 +1,61 @@
1
+ <div class="experiment">
2
+ <h3 class='experiment-title'>Experiment: <%= @experiment.id %></h3>
3
+ <div class='experiment-sections'>
4
+ <div class='experiment-section'>
5
+ <h5 class='experiment-section-title'>Proportions</h5>
6
+ <div class='experiment-table'>
7
+ <div class="row header">
8
+ <div class='column'>
9
+ Variant
10
+ </div>
11
+ <div class='column'>
12
+ Percentage
13
+ </div>
14
+ </div>
15
+ <form method='post' action="<%= update_percentages_url(@experiment) %>">
16
+ <% @experiment.variants.each do |variant| %>
17
+ <div class="row">
18
+ <div class='column'>
19
+ <%= variant.id %>
20
+ </div>
21
+ <div class='column'>
22
+ <input name="variants[<%= variant.id %>]" type="number" placeholder="percentage" value="<%= variant.percentage %>" />
23
+ </div>
24
+ </div>
25
+ <% end %>
26
+ <input type="submit" value='Save' />
27
+ </form>
28
+ </div>
29
+ </div>
30
+
31
+ <div class='experiment-section'>
32
+ <h5 class='experiment-section-title'>Assign users to variant</h5>
33
+ <form class='user-id-input-form' method='post' action="<%= assign_users_to_variant_url(@experiment) %>">
34
+ <div class='input-group'>
35
+ <label for='variant'>Variant:</label>
36
+ <select name='variant_id' id='variant'>
37
+ <% @experiment.variants.each do |variant| %>
38
+ <option value="<%= variant.id %>"><%= variant.id %></option>
39
+ <% end %>
40
+ </select>
41
+ </div>
42
+ <div class='input-group'>
43
+ <label for='user_ids'>User IDs:</label>
44
+ <textarea name='user_ids' class='user_ids' placeholder="one user id per line" cols='50' rows='10'></textarea>
45
+ </div>
46
+ <div class='input-group'>
47
+ <input type="submit" value='Assign users' />
48
+ </div>
49
+ </form>
50
+ </div>
51
+
52
+ <div class='experiment-section'>
53
+ <h5 class='experiment-section-title'>Reset experiment</h5>
54
+ <form class='user-id-input-form' method='post' action="<%= reset_experiment_url(@experiment) %>">
55
+ <div class='input-group'>
56
+ <input type="submit" value='Reset Experiment' onclick="return confirm('Are you sure you want to reset this experiment?');" />
57
+ </div>
58
+ </form>
59
+ </div>
60
+ </div>
61
+ </div>
@@ -0,0 +1,8 @@
1
+ <% if @experiments.any? %>
2
+ <% @experiments.each do |experiment| %>
3
+ <%= erb :_experiment, locals: { experiment: experiment } %>
4
+ <% end %>
5
+ <% else %>
6
+ <p class="intro">No experiments have started yet, you need to define them in your code and introduce them to your users.</p>
7
+ <p class="intro">Check out the <a href='https://github.com/butternutbox/laboratory#readme'>Readme</a> for more help getting started.</p>
8
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta content='text/html; charset=utf-8' http-equiv='Content-Type'>
5
+ <link href="<%= url 'reset.css' %>" media="screen" rel="stylesheet" type="text/css">
6
+ <link href="<%= url 'style.css' %>" media="screen" rel="stylesheet" type="text/css">
7
+ <title>Laboratory</title>
8
+
9
+ </head>
10
+ <body>
11
+ <div class="main">
12
+ <div class='content'>
13
+ <div class="header">
14
+ <h1>Laboratory 🧪</h1>
15
+ </div>
16
+ <%= yield %>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="footer">
21
+ <p>Powered by <a href="https://github.com/butternutbox/laboratory">Laboratory</a> 🧪. Handmade with love by your friends at <a href="https://github.com/butternutbox">Butternut Box</a> ❤️</p>
22
+ </div>
23
+ </body>
24
+ </html>
@@ -0,0 +1,59 @@
1
+ require 'sinatra/base'
2
+ require 'laboratory'
3
+ require 'laboratory/ui/helpers'
4
+
5
+ module Laboratory
6
+ class UI < Sinatra::Base
7
+ dir = File.dirname(File.expand_path(__FILE__))
8
+
9
+ set :views, "#{dir}/ui/views"
10
+ set :public_folder, "#{dir}/ui/public"
11
+ set :static, true
12
+ set :method_override, true
13
+
14
+ helpers Laboratory::UIHelpers
15
+
16
+ get '/' do
17
+ @experiments = Laboratory::Experiment.all
18
+ erb :index
19
+ end
20
+
21
+ get '/experiments/:id/edit' do
22
+ @experiment = Laboratory::Experiment.find(params[:id])
23
+ erb :edit
24
+ end
25
+
26
+ # params = {variants: { control => 40, variant_a => 60 }}
27
+ post '/experiments/:id/update_percentages' do
28
+ experiment = Laboratory::Experiment.find(params[:id])
29
+
30
+ params[:variants].each do |variant_id, percentage|
31
+ variant = experiment.variants.find { |v| v.id == variant_id }
32
+ variant.percentage = percentage.to_i
33
+ end
34
+
35
+ experiment.save
36
+ redirect experiment_url(experiment)
37
+ end
38
+
39
+ # params = {variant_id: 'control', user_ids: []}
40
+ post '/experiments/:id/assign_users' do
41
+ experiment = Laboratory::Experiment.find(params[:id])
42
+ variant = experiment.variants.find { |v| v.id == params[:variant_id] }
43
+ user_ids = params[:user_ids].split("\r\n")
44
+
45
+ user_ids.each do |user_id|
46
+ user = Laboratory::User.new(id: user_id)
47
+ experiment.assign_to_variant(variant.id, user: user)
48
+ end
49
+
50
+ redirect experiment_url(experiment)
51
+ end
52
+
53
+ post '/experiments/:id/reset' do
54
+ experiment = Laboratory::Experiment.find(params[:id])
55
+ experiment.reset
56
+ redirect experiment_url(experiment)
57
+ end
58
+ end
59
+ end
@@ -1,3 +1,3 @@
1
1
  module Laboratory
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
data/lib/laboratory.rb CHANGED
@@ -10,6 +10,9 @@ require 'laboratory/experiment/variant'
10
10
  require 'laboratory/experiment/changelog_item'
11
11
  require 'laboratory/experiment/event'
12
12
  require 'laboratory/experiment/event/recording'
13
+ require 'laboratory/experiment/analysis_summary'
14
+ require 'laboratory/calculations/z_score'
15
+ require 'laboratory/calculations/confidence_level'
13
16
 
14
17
  module Laboratory
15
18
  class << self
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: laboratory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Niall Paterson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-03-20 00:00:00.000000000 Z
11
+ date: 2020-03-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -24,6 +24,34 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: sinatra
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 1.2.6
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 1.2.6
41
+ - !ruby/object:Gem::Dependency
42
+ name: rack-test
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.1'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.1'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: rspec
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -76,12 +104,23 @@ files:
76
104
  - lib/laboratory/adapters/redis_adapter.rb
77
105
  - lib/laboratory/algorithms.rb
78
106
  - lib/laboratory/algorithms/random.rb
107
+ - lib/laboratory/calculations/confidence_level.rb
108
+ - lib/laboratory/calculations/z_score.rb
79
109
  - lib/laboratory/config.rb
80
110
  - lib/laboratory/experiment.rb
111
+ - lib/laboratory/experiment/analysis_summary.rb
81
112
  - lib/laboratory/experiment/changelog_item.rb
82
113
  - lib/laboratory/experiment/event.rb
83
114
  - lib/laboratory/experiment/event/recording.rb
84
115
  - lib/laboratory/experiment/variant.rb
116
+ - lib/laboratory/ui.rb
117
+ - lib/laboratory/ui/helpers.rb
118
+ - lib/laboratory/ui/public/reset.css
119
+ - lib/laboratory/ui/public/style.css
120
+ - lib/laboratory/ui/views/_experiment.erb
121
+ - lib/laboratory/ui/views/edit.erb
122
+ - lib/laboratory/ui/views/index.erb
123
+ - lib/laboratory/ui/views/layout.erb
85
124
  - lib/laboratory/user.rb
86
125
  - lib/laboratory/version.rb
87
126
  homepage: https://github.com/butternutbox/laboratory