laboratory 0.1.0 → 0.1.1

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 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