eeny-meeny 1.0.0
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 +7 -0
- data/CHANGELOG.md +11 -0
- data/Gemfile +7 -0
- data/LICENSE +21 -0
- data/README.md +86 -0
- data/Rakefile +7 -0
- data/eeny-meeny.gemspec +23 -0
- data/lib/eeny-meeny.rb +6 -0
- data/lib/eeny-meeny/encryptor.rb +120 -0
- data/lib/eeny-meeny/experiment.rb +30 -0
- data/lib/eeny-meeny/experiment_helper.rb +21 -0
- data/lib/eeny-meeny/middleware.rb +64 -0
- data/lib/eeny-meeny/middleware_helper.rb +25 -0
- data/lib/eeny-meeny/railtie.rb +27 -0
- data/lib/eeny-meeny/route_constraint.rb +33 -0
- data/lib/eeny-meeny/shared_methods.rb +23 -0
- data/lib/eeny-meeny/variation.rb +20 -0
- data/lib/eeny-meeny/version.rb +3 -0
- data/spec/eeny-meeny/experiment_spec.rb +62 -0
- data/spec/eeny-meeny/middleware_spec.rb +77 -0
- data/spec/eeny-meeny/variation_spec.rb +49 -0
- data/spec/fixtures/experiments.yml +10 -0
- data/spec/mock_rack_app.rb +19 -0
- data/spec/spec_helper.rb +24 -0
- metadata +177 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e28ef5a3f25b3acf3e554f578623aab014bc8033
|
4
|
+
data.tar.gz: 017bf53a5cca472993e25c1d09cc514e3a0983b9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e188c4dd1ceeee62fdd3efc5ab445808d49b34cb75579dbe591c381c6fab73c2206261473ad27d7dd8df3e72ca4d5d86508ef5d97ad8aba2c2661e2dee926b58
|
7
|
+
data.tar.gz: e7f33467b3c326a158405a528e4ca787afc05061fca5e0fa4581a083f00f7523bc9ebbb6bb1e0d36e1274e41d09e712fef0685bc527c4630de6b89bb1d970437
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
### 1.0.0 (2016-07-03)
|
2
|
+
|
3
|
+
Features:
|
4
|
+
|
5
|
+
- Initial version of split testing tool. Includes:
|
6
|
+
- Experiment helpers for Rails
|
7
|
+
- Run any number of experiments at the same time.
|
8
|
+
- Have any number of variations in each experiment.
|
9
|
+
- Set the weight (likelyhood of participants excountering) each experiment variation customly.
|
10
|
+
- Encrypt experiment cookies.
|
11
|
+
- Route constraint for full page split tests.
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2016 Christian Orthmann
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
eeny-meeny
|
2
|
+
==========================
|
3
|
+
[](https://codeclimate.com/github/corthmann/eeny-meeny)
|
4
|
+
[](https://codeclimate.com/github/corthmann/eeny-meeny/coverage)
|
5
|
+
|
6
|
+
Installation
|
7
|
+
-------------
|
8
|
+
You can install this gem by using the following command:
|
9
|
+
```
|
10
|
+
gem install eeny-meeny
|
11
|
+
```
|
12
|
+
or by adding the the following line to your Gemfile.
|
13
|
+
```
|
14
|
+
gem 'eeny-meeny'
|
15
|
+
```
|
16
|
+
|
17
|
+
Configuration
|
18
|
+
-------------
|
19
|
+
`eeny-meeny` should be configured in your Rails environment file. Preferably loaded through `secrets.yml`
|
20
|
+
|
21
|
+
The following configurations are available:
|
22
|
+
|
23
|
+
* `config.eeny_meeny.cookies.path` Defaults to `'/'`. Sets the `path` cookie attribute. If this configuartion is set to `nil` it means that each page will get its own cookie.
|
24
|
+
* `config.eeny_meeny.cookies.same_site` Defaults to `:strict`. Accepts: `:strict`, `:lax` and `nil`. Sets the `SameSite` cookie attribute. Selecting `nil` will disable the header on the cookie.
|
25
|
+
* `config.eeny_meeny.secure` Boolean value. Defaults to `true` and determines if experiment cookies should be encrypted or not.
|
26
|
+
* `config.eeny_meeny.secret` sets the secret used for encrypting experiment cookies.
|
27
|
+
* `config.eeny_meeny.experiments` list of experiment-data. It is easiest to load this from a `.yml` file with the following structure:
|
28
|
+
|
29
|
+
```
|
30
|
+
:experiment_1:
|
31
|
+
:name: Awesome Experiment
|
32
|
+
:version: 1
|
33
|
+
:variations:
|
34
|
+
:a:
|
35
|
+
:name: Variation A
|
36
|
+
:weight: 0.8
|
37
|
+
:options:
|
38
|
+
:message: A rocks, B sucks
|
39
|
+
:b:
|
40
|
+
:name: Variation B
|
41
|
+
:weight: 0.2
|
42
|
+
:options:
|
43
|
+
:message: B is an all-star!
|
44
|
+
```
|
45
|
+
|
46
|
+
Usage
|
47
|
+
-------------
|
48
|
+
`eeny-meeny` adds the following helpers to your controllers and views:
|
49
|
+
|
50
|
+
* `participates_in?(experiement_id, variation_id: nil)` Returns the chosen variation for the current user if he participates in the experiment.
|
51
|
+
|
52
|
+
Full page split tests
|
53
|
+
-------------
|
54
|
+
If you want to completely redesign a page but test it in production as a split test against your old page, using identical routes, then it can be achieved as follows:
|
55
|
+
|
56
|
+
1. Create an experiment like this:
|
57
|
+
|
58
|
+
```
|
59
|
+
:example_page:
|
60
|
+
:name: Test V1 vs. V2
|
61
|
+
:v1:
|
62
|
+
:name: First version of the page
|
63
|
+
:weight: 0.9
|
64
|
+
:v2:
|
65
|
+
:name: Second version of the page
|
66
|
+
:weight: 0.1
|
67
|
+
```
|
68
|
+
|
69
|
+
2. Namespace your controller and views (ex. `ExamplesController` becommes `V1::ExamplesController` )
|
70
|
+
3. Copy the route(s) for `ExamplesController` and use `controller: 'v1/examples`
|
71
|
+
4. Add `require 'eeny-meeny/route_constraint` to `routes.rb`
|
72
|
+
4. Surround your `v1/examples` route(s) with the following constraint:
|
73
|
+
|
74
|
+
```
|
75
|
+
constraints(EenyMeeny::RouteConstraint.new(:example_page, variation_id: :v1) do
|
76
|
+
# your v1 routes goes here.
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
Now 90% of the users will experience V1 and 10% will experience V2.
|
81
|
+
|
82
|
+
Special thanks
|
83
|
+
-------------
|
84
|
+
As part of building this gem I borrowed the `Encryptor` class from the `encrypted_cookie` gem (https://github.com/cvonkleist/encrypted_cookie)
|
85
|
+
|
86
|
+
All credits for the cookie encryption goes to that project.
|
data/Rakefile
ADDED
data/eeny-meeny.gemspec
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require File.expand_path('../lib/eeny-meeny/version', __FILE__)
|
2
|
+
|
3
|
+
Gem::Specification.new do |s|
|
4
|
+
s.name = 'eeny-meeny'
|
5
|
+
s.version = EenyMeeny::VERSION.dup
|
6
|
+
s.date = '2016-07-03'
|
7
|
+
s.summary = "A simple split testing tool for Rails"
|
8
|
+
s.authors = ["Christian Orthmann"]
|
9
|
+
s.email = 'christian.orthmann@gmail.com'
|
10
|
+
s.require_path = 'lib'
|
11
|
+
s.files = `git ls-files`.split("\n") - %w(.rvmrc .gitignore)
|
12
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - %w(.rvmrc .gitignore)
|
13
|
+
s.homepage = 'http://rubygems.org/gems/eeny-meeny'
|
14
|
+
s.license = 'MIT'
|
15
|
+
|
16
|
+
s.add_development_dependency('rake', '~> 10')
|
17
|
+
s.add_development_dependency('rspec', '~> 3')
|
18
|
+
s.add_development_dependency('simplecov', '~> 0')
|
19
|
+
s.add_development_dependency('simplecov-rcov', '~> 0')
|
20
|
+
s.add_development_dependency('yard', '~> 0')
|
21
|
+
s.add_runtime_dependency('rack', '>= 1.2.1', '< 2')
|
22
|
+
s.add_runtime_dependency('activesupport', '>= 3.0.0', '< 5.0.0')
|
23
|
+
end
|
data/lib/eeny-meeny.rb
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'rack/utils'
|
3
|
+
|
4
|
+
module EenyMeeny
|
5
|
+
# Encrypts messages with authentication
|
6
|
+
#
|
7
|
+
# The use of authentication is essential to avoid Chosen Ciphertext
|
8
|
+
# Attacks. By using this in an encrypt then MAC form, we avoid some
|
9
|
+
# attacks such as e.g. being used as a CBC padding oracle to decrypt
|
10
|
+
# the ciphertext.
|
11
|
+
class Encryptor
|
12
|
+
# Create the encryptor
|
13
|
+
#
|
14
|
+
# Pass in the secret, which should be at least 32-bytes worth of
|
15
|
+
# entropy, e.g. a string generated by `SecureRandom.hex(32)`.
|
16
|
+
# This also allows specification of the algorithm for the cipher
|
17
|
+
# and MAC. But don't change that unless you're very sure.
|
18
|
+
def initialize(secret, cipher = 'aes-256-cbc', hmac = 'SHA256')
|
19
|
+
@cipher = cipher
|
20
|
+
@hmac = hmac
|
21
|
+
|
22
|
+
# use the HMAC to derive two independent keys for the encryption and
|
23
|
+
# authentication of ciphertexts It is bad practice to use the same key
|
24
|
+
# for encryption and authentication. This also allows us to use all
|
25
|
+
# of the entropy in a long key (e.g. 64 hex bytes) when straight
|
26
|
+
# assignement would could result in assigning a key with a much
|
27
|
+
# reduced key space. Also, the personalisation strings further help
|
28
|
+
# reduce the possibility of key reuse by ensuring it should be unique
|
29
|
+
# to this gem, even with shared secrets.
|
30
|
+
@encryption_key = hmac("EncryptedCookie Encryption", secret)
|
31
|
+
@authentication_key = hmac("EncryptedCookie Authentication", secret)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Encrypts message
|
35
|
+
#
|
36
|
+
# Returns the base64 encoded ciphertext plus IV. In addtion, the
|
37
|
+
# message is prepended with a MAC code to prevent chosen ciphertext
|
38
|
+
# attacks.
|
39
|
+
def encrypt(message)
|
40
|
+
# encrypt the message
|
41
|
+
encrypted = encrypt_message(message)
|
42
|
+
|
43
|
+
[authenticate_message(encrypted) + encrypted].pack('m0')
|
44
|
+
end
|
45
|
+
|
46
|
+
# decrypts base64 encoded ciphertext
|
47
|
+
#
|
48
|
+
# First, it checks the message tag and returns nil if that fails to verify.
|
49
|
+
# Otherwise, the data is passed on to the AES function for decryption.
|
50
|
+
def decrypt(ciphertext)
|
51
|
+
ciphertext = ciphertext.unpack('m').first
|
52
|
+
tag = ciphertext[0, hmac_length]
|
53
|
+
ciphertext = ciphertext[hmac_length..-1]
|
54
|
+
|
55
|
+
# make sure we actually had enough data for the tag too.
|
56
|
+
if tag && ciphertext && verify_message(tag, ciphertext)
|
57
|
+
decrypt_ciphertext(ciphertext)
|
58
|
+
else
|
59
|
+
nil
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# HMAC digest of the message using the given secret
|
66
|
+
def hmac(secret, message)
|
67
|
+
OpenSSL::HMAC.digest(@hmac, secret, message)
|
68
|
+
end
|
69
|
+
|
70
|
+
def hmac_length
|
71
|
+
OpenSSL::Digest.new(@hmac).size
|
72
|
+
end
|
73
|
+
|
74
|
+
# returns the message authentication tag
|
75
|
+
#
|
76
|
+
# This is computed as HMAC(authentication_key, message)
|
77
|
+
def authenticate_message(message)
|
78
|
+
hmac(@authentication_key, message)
|
79
|
+
end
|
80
|
+
|
81
|
+
# verifies the message
|
82
|
+
#
|
83
|
+
# This does its best to be constant time, by use of the rack secure compare
|
84
|
+
# function.
|
85
|
+
def verify_message(tag, message)
|
86
|
+
own_tag = authenticate_message(message)
|
87
|
+
Rack::Utils.secure_compare(tag, own_tag)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Encrypt
|
91
|
+
#
|
92
|
+
# Encrypts the given message with a random IV, then returns the ciphertext
|
93
|
+
# with the IV prepended.
|
94
|
+
def encrypt_message(message)
|
95
|
+
aes = OpenSSL::Cipher::Cipher.new(@cipher).encrypt
|
96
|
+
aes.key = @encryption_key
|
97
|
+
iv = aes.random_iv
|
98
|
+
aes.iv = iv
|
99
|
+
iv + (aes.update(message) << aes.final)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Decrypt
|
103
|
+
#
|
104
|
+
# Pulls the IV off the front of the message and decrypts. Catches
|
105
|
+
# OpenSSL errors and returns nil. But this should never happen, as the
|
106
|
+
# verify method should catch all corrupted ciphertexts.
|
107
|
+
def decrypt_ciphertext(ciphertext)
|
108
|
+
aes = OpenSSL::Cipher::Cipher.new(@cipher).decrypt
|
109
|
+
aes.key = @encryption_key
|
110
|
+
iv = ciphertext[0, aes.iv_len]
|
111
|
+
aes.iv = iv
|
112
|
+
crypted_text = ciphertext[aes.iv_len..-1]
|
113
|
+
return nil if crypted_text.nil? || iv.nil?
|
114
|
+
aes.update(crypted_text) << aes.final
|
115
|
+
rescue
|
116
|
+
nil
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'eeny-meeny/variation'
|
2
|
+
require 'active_support/time'
|
3
|
+
require 'active_support/core_ext/enumerable'
|
4
|
+
|
5
|
+
module EenyMeeny
|
6
|
+
class Experiment
|
7
|
+
attr_reader :id, :name, :version, :variations, :total_weight, :end_at, :start_at
|
8
|
+
|
9
|
+
def initialize(id, name: '', version: 1, variations: {}, start_at: nil, end_at: nil)
|
10
|
+
@id = id
|
11
|
+
@name = name
|
12
|
+
@version = version
|
13
|
+
@variations = variations.map do |variation_id, variation|
|
14
|
+
Variation.new(variation_id, **variation)
|
15
|
+
end
|
16
|
+
@total_weight = (@variations.empty? ? 1.0 : @variations.sum { |variation| variation.weight.to_f })
|
17
|
+
|
18
|
+
@start_at = Time.zone.parse(start_at) if start_at
|
19
|
+
@end_at = Time.zone.parse(end_at) if end_at
|
20
|
+
end
|
21
|
+
|
22
|
+
def pick_variation
|
23
|
+
Hash[
|
24
|
+
@variations.map do |variation|
|
25
|
+
[variation, variation.weight]
|
26
|
+
end
|
27
|
+
].max_by { |_, weight| rand ** (@total_weight / weight) }.first
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'eeny-meeny/shared_methods'
|
2
|
+
|
3
|
+
module EenyMeeny::ExperimentHelper
|
4
|
+
@@eeny_meeny_encryptor = nil
|
5
|
+
|
6
|
+
def participates_in?(experiment_id, variation_id: nil)
|
7
|
+
cookie = eeny_meeny_cookie(experiment_id)
|
8
|
+
cookie[:variation] unless cookie.nil? || (variation_id.present? && variation_id != cookie[:variation].id)
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def eeny_meeny_cookie(experiment_id)
|
14
|
+
cookie = cookies[EenyMeeny::EENY_MEENY_COOKIE_PREFIX+experiment_id.to_s+'_v'+experiment_version(experiment_id).to_s]
|
15
|
+
if cookie
|
16
|
+
Marshal.load(decrypt(cookie)) rescue nil
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
include EenyMeeny::SharedMethods
|
21
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'rack'
|
2
|
+
require 'time'
|
3
|
+
require 'active_support/time'
|
4
|
+
require 'eeny-meeny/middleware_helper'
|
5
|
+
require 'eeny-meeny/experiment'
|
6
|
+
require 'eeny-meeny/encryptor'
|
7
|
+
|
8
|
+
module EenyMeeny
|
9
|
+
class Middleware
|
10
|
+
include EenyMeeny::MiddlewareHelper
|
11
|
+
|
12
|
+
def initialize(app, experiments, secure, secret, cookie_path, cookie_same_site)
|
13
|
+
@app = app
|
14
|
+
@experiments = experiments.map do |id, experiment|
|
15
|
+
EenyMeeny::Experiment.new(id, **experiment)
|
16
|
+
end
|
17
|
+
@secure = secure
|
18
|
+
@cookie_config = { path: cookie_path, same_site: cookie_same_site }
|
19
|
+
@encryptor = EenyMeeny::Encryptor.new(secret) if secure
|
20
|
+
end
|
21
|
+
|
22
|
+
def call(env)
|
23
|
+
request = Rack::Request.new(env)
|
24
|
+
cookies = request.cookies
|
25
|
+
now = Time.zone.now
|
26
|
+
new_cookies = {}
|
27
|
+
existing_set_cookie_header = env['Set-Cookie']
|
28
|
+
# Prepare for experiments.
|
29
|
+
@experiments.each do |experiment|
|
30
|
+
# Skip experiments that haven't started yet or if it ended
|
31
|
+
next if experiment.start_at && (now < experiment.start_at)
|
32
|
+
next if experiment.end_at && (now > experiment.end_at)
|
33
|
+
# skip experiments that already have a cookie
|
34
|
+
unless has_experiment_cookie?(cookies, experiment)
|
35
|
+
env['Set-Cookie'] = ''
|
36
|
+
cookie_value = generate_cookie_value(experiment, @cookie_config)
|
37
|
+
cookie_value[:value] = @encryptor.encrypt(cookie_value[:value]) if @secure
|
38
|
+
# Set HTTP_COOKIE header to enable experiment on first pageview
|
39
|
+
Rack::Utils.set_cookie_header!(env,
|
40
|
+
experiment_cookie_name(experiment),
|
41
|
+
cookie_value)
|
42
|
+
env['HTTP_COOKIE'] = '' if env['HTTP_COOKIE'].nil?
|
43
|
+
env['HTTP_COOKIE'] += '; ' unless env['HTTP_COOKIE'].empty?
|
44
|
+
env['HTTP_COOKIE'] += env['Set-Cookie']
|
45
|
+
new_cookies[experiment_cookie_name(experiment)] = cookie_value
|
46
|
+
end
|
47
|
+
end
|
48
|
+
# Clean up 'Set-Cookie' header.
|
49
|
+
if existing_set_cookie_header.nil?
|
50
|
+
env.delete('Set-Cookie')
|
51
|
+
else
|
52
|
+
env['Set-Cookie'] = existing_set_cookie_header
|
53
|
+
end
|
54
|
+
# Delegate to app
|
55
|
+
status, headers, body = @app.call(env)
|
56
|
+
response = Rack::Response.new(body, status, headers)
|
57
|
+
# Add new cookies to 'Set-Cookie' header
|
58
|
+
new_cookies.each do |key, value|
|
59
|
+
response.set_cookie(key,value)
|
60
|
+
end
|
61
|
+
response.finish
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module EenyMeeny::MiddlewareHelper
|
2
|
+
def has_experiment_cookie?(cookies, experiment)
|
3
|
+
cookies.has_key?(experiment_cookie_name(experiment))
|
4
|
+
end
|
5
|
+
|
6
|
+
def generate_cookie_value(experiment, cookie_config)
|
7
|
+
variation = experiment.pick_variation
|
8
|
+
cookie = {
|
9
|
+
expires: (experiment.end_at || 1.year.from_now),
|
10
|
+
httponly: true,
|
11
|
+
value: Marshal.dump({
|
12
|
+
name: experiment.name,
|
13
|
+
variation: variation,
|
14
|
+
})
|
15
|
+
}
|
16
|
+
cookie[:same_site] = cookie_config[:same_site] unless cookie_config[:same_site].nil?
|
17
|
+
cookie[:path] = cookie_config[:path] unless cookie_config[:path].nil?
|
18
|
+
cookie
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
def experiment_cookie_name(experiment)
|
23
|
+
EenyMeeny::EENY_MEENY_COOKIE_PREFIX+experiment.id.to_s+'_v'+experiment.version.to_s
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'eeny-meeny/experiment_helper'
|
2
|
+
require 'eeny-meeny/middleware'
|
3
|
+
|
4
|
+
module EenyMeeny
|
5
|
+
class Railtie < Rails::Railtie
|
6
|
+
config.eeny_meeny = ActiveSupport::OrderedOptions.new
|
7
|
+
# default config values. these can be changed in the rails environment configuration.
|
8
|
+
config.eeny_meeny.experiments = []
|
9
|
+
config.eeny_meeny.secure = true
|
10
|
+
config.eeny_meeny.secret = '9fc8b966eca7d03d55df40c01c10b8e02bf1f9d12d27b8968d53eb53e8c239902d00bf6afae5e726ce1111159eeb2f8f0e77233405db1d82dd71397f651a0a4f'
|
11
|
+
config.eeny_meeny.cookies = ActiveSupport::OrderedOptions.new
|
12
|
+
config.eeny_meeny.cookies.path = '/'
|
13
|
+
config.eeny_meeny.cookies.same_site = :strict
|
14
|
+
|
15
|
+
initializer 'eeny_meeny.initialize' do |app|
|
16
|
+
ActionController::Base.send :include, EenyMeeny::ExperimentHelper
|
17
|
+
ActionView::Base.send :include, EenyMeeny::ExperimentHelper
|
18
|
+
|
19
|
+
app.middleware.insert_before 'ActionDispatch::Cookies', EenyMeeny::Middleware,
|
20
|
+
config.eeny_meeny.experiments,
|
21
|
+
config.eeny_meeny.secure,
|
22
|
+
config.eeny_meeny.secret,
|
23
|
+
config.eeny_meeny.cookies.path,
|
24
|
+
config.eeny_meeny.cookies.same_site
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'eeny-meeny/shared_methods'
|
2
|
+
|
3
|
+
module EenyMeeny
|
4
|
+
class RouteConstraint
|
5
|
+
@@eeny_meeny_encryptor = nil
|
6
|
+
|
7
|
+
def initialize(experiment_id, variation_id: nil)
|
8
|
+
@experiment_id = experiment_id
|
9
|
+
@variation_id = variation_id
|
10
|
+
@version = experiment_version(experiment_id)
|
11
|
+
end
|
12
|
+
|
13
|
+
def matches?(request)
|
14
|
+
!participates_in?(request).nil?
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def participates_in?(request)
|
20
|
+
cookie = eeny_meeny_cookie(request)
|
21
|
+
cookie[:variation] unless cookie.nil? || (!cookie.nil? && @variation_id.present? && @variation_id != cookie[:variation].id)
|
22
|
+
end
|
23
|
+
|
24
|
+
def eeny_meeny_cookie(request)
|
25
|
+
cookie = request.cookie_jar[EenyMeeny::EENY_MEENY_COOKIE_PREFIX+@experiment_id.to_s+'_v'+@version.to_s]
|
26
|
+
if cookie
|
27
|
+
Marshal.load(decrypt(cookie)) rescue nil
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
include EenyMeeny::SharedMethods
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module EenyMeeny::SharedMethods
|
2
|
+
|
3
|
+
private
|
4
|
+
|
5
|
+
def experiment_version(experiment_id)
|
6
|
+
(Rails.application.config.eeny_meeny.experiments.
|
7
|
+
try(:[], experiment_id.to_sym).try(:[], :version) || 1) rescue 1
|
8
|
+
end
|
9
|
+
|
10
|
+
def decrypt(cookie)
|
11
|
+
begin
|
12
|
+
if Rails.application.config.eeny_meeny.secure
|
13
|
+
# Memoize encryptor to avoid creating new instances on every request.
|
14
|
+
@@eeny_meeny_encryptor ||= EenyMeeny::Encryptor.new(Rails.application.config.eeny_meeny.secret)
|
15
|
+
@@eeny_meeny_encryptor.decrypt(cookie)
|
16
|
+
else
|
17
|
+
cookie
|
18
|
+
end
|
19
|
+
rescue
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module EenyMeeny
|
2
|
+
class Variation
|
3
|
+
attr_reader :id, :name, :weight, :options
|
4
|
+
|
5
|
+
def initialize(id, name: '', weight: 1, **options)
|
6
|
+
@id = id
|
7
|
+
@name = name
|
8
|
+
@weight = weight
|
9
|
+
@options = options
|
10
|
+
end
|
11
|
+
|
12
|
+
def marshal_dump
|
13
|
+
[@id, { name: @name, weight: @weight, **@options }]
|
14
|
+
end
|
15
|
+
|
16
|
+
def marshal_load(array)
|
17
|
+
send :initialize, array[0], **array[1]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'eeny-meeny/experiment'
|
3
|
+
require 'eeny-meeny/variation'
|
4
|
+
|
5
|
+
describe EenyMeeny::Experiment do
|
6
|
+
describe 'when initialized' do
|
7
|
+
|
8
|
+
context 'with weighted variations' do
|
9
|
+
subject do
|
10
|
+
described_class.new(:experiment_1,
|
11
|
+
name: 'Test 1',
|
12
|
+
variations: {
|
13
|
+
a: { name: 'A', weight: 0.5 },
|
14
|
+
b: { name: 'B', weight: 0.3 }})
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'sets the instance variables' do
|
18
|
+
expect(subject.id).to eq(:experiment_1)
|
19
|
+
expect(subject.name).to eq('Test 1')
|
20
|
+
expect(subject.variations).to be_a Array
|
21
|
+
expect(subject.variations.size).to eq(2)
|
22
|
+
end
|
23
|
+
|
24
|
+
it "has a 'total_weight' equal to the sum of the variation weights" do
|
25
|
+
expect(subject.total_weight).to eq(0.8)
|
26
|
+
end
|
27
|
+
|
28
|
+
describe '#pick_variation' do
|
29
|
+
it 'picks a variation' do
|
30
|
+
expect(subject.pick_variation).to be_a EenyMeeny::Variation
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
context 'with non-weighted variations' do
|
36
|
+
subject do
|
37
|
+
described_class.new(:experiment_1,
|
38
|
+
name: 'Test 1',
|
39
|
+
variations: {
|
40
|
+
a: { name: 'A' },
|
41
|
+
b: { name: 'B' }})
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'sets the instance variables' do
|
45
|
+
expect(subject.id).to eq(:experiment_1)
|
46
|
+
expect(subject.name).to eq('Test 1')
|
47
|
+
expect(subject.variations).to be_a Array
|
48
|
+
expect(subject.variations.size).to eq(2)
|
49
|
+
end
|
50
|
+
|
51
|
+
it "has a 'total_weight' equal to the number of the variation weights" do
|
52
|
+
expect(subject.total_weight).to eq(2)
|
53
|
+
end
|
54
|
+
|
55
|
+
describe '#pick_variation' do
|
56
|
+
it 'picks a variation' do
|
57
|
+
expect(subject.pick_variation).to be_a EenyMeeny::Variation
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'eeny-meeny/encryptor'
|
3
|
+
require 'eeny-meeny/middleware'
|
4
|
+
|
5
|
+
def initialize_app(secure: true, secret: 'test', path: '/', same_site: :strict)
|
6
|
+
experiments = YAML.load_file(File.join('spec','fixtures','experiments.yml'))
|
7
|
+
described_class.new(app, experiments, secure, secret, path, same_site)
|
8
|
+
end
|
9
|
+
|
10
|
+
describe EenyMeeny::Middleware do
|
11
|
+
|
12
|
+
let(:app) { MockRackApp.new }
|
13
|
+
before(:example) do
|
14
|
+
allow(Time).to receive_message_chain(:zone, :now) { Time.now }
|
15
|
+
end
|
16
|
+
|
17
|
+
describe 'when initialized' do
|
18
|
+
|
19
|
+
context "with 'config.eeny_meeny.secure = true'" do
|
20
|
+
it 'creates an instance of EenyMeeny::Encryptor' do
|
21
|
+
instance = initialize_app
|
22
|
+
expect(instance.instance_variable_get(:@secure)).to be true
|
23
|
+
expect(instance.instance_variable_get(:@encryptor)).to be_a EenyMeeny::Encryptor
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
context "with 'config.eeny_meeny.secure = false'" do
|
28
|
+
it 'does not have an instance of EenyMeeny::Encryptor' do
|
29
|
+
instance = initialize_app(secure: false)
|
30
|
+
expect(instance.instance_variable_get(:@secure)).to be false
|
31
|
+
expect(instance.instance_variable_get(:@encryptor)).to be nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
describe 'when called with a GET request' do
|
37
|
+
subject { initialize_app }
|
38
|
+
|
39
|
+
context "and the request doesn't contain the experiment cookie" do
|
40
|
+
let(:request) { Rack::MockRequest.new(subject) }
|
41
|
+
|
42
|
+
before(:example) do
|
43
|
+
@response = request.get('/test', 'CONTENT_TYPE' => 'text/plain')
|
44
|
+
end
|
45
|
+
|
46
|
+
it "sets the 'HTTP_COOKIE' header on the request" do
|
47
|
+
expect(app['HTTP_COOKIE']).to be
|
48
|
+
expect(app['HTTP_COOKIE'].length).to be > 0
|
49
|
+
end
|
50
|
+
|
51
|
+
it "sets the 'Set-Cookie' header on the response" do
|
52
|
+
expect(@response['Set-Cookie']).to be
|
53
|
+
expect(@response['Set-Cookie'].length).to be > 0
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context 'and the request already contains the experiment cookie' do
|
58
|
+
let(:request) { Rack::MockRequest.new(subject) }
|
59
|
+
|
60
|
+
before(:example) do
|
61
|
+
@original_request_cookies = 'test=abc;eeny_meeny_my_page_v1=on1tOQ5hiKdA0biVZVwvTUQcmkODacwdpi%2FedQJIYQz9KdWYAXqzCafF5Dqqa6xtHFBdXYVmz%2Bp4%2FigmKz4hBVYZbJU%2FMwBbvYG%2BIoBelk10PxwtyxbA%2BiDzFT4jZeiTkNOmZ3rp1Gzz74JjT4aocqB187p7SrpeM2jfyZ8ZKPOiZs6tXf0QoXkV%2BZbtxJLRPr5lgmGxslfM8vCIm1%2F0HQ%3D%3D;'
|
62
|
+
@response = request.get('/test',
|
63
|
+
'CONTENT_TYPE' => 'text/plain',
|
64
|
+
'HTTP_COOKIE' => @original_request_cookies)
|
65
|
+
end
|
66
|
+
|
67
|
+
it "does not change the 'HTTP_COOKIE' header on the request" do
|
68
|
+
expect(app['HTTP_COOKIE']).to eq(@original_request_cookies)
|
69
|
+
end
|
70
|
+
|
71
|
+
it "does not set the 'Set-Cookie' header on the response" do
|
72
|
+
expect(@response['Set-Cookie']).to be nil
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'eeny-meeny/variation'
|
3
|
+
|
4
|
+
describe EenyMeeny::Variation do
|
5
|
+
describe 'when initialized' do
|
6
|
+
|
7
|
+
subject do
|
8
|
+
described_class.new(:a,
|
9
|
+
name: 'A',
|
10
|
+
weight: 0.5,
|
11
|
+
custom_option_1: 'asd1',
|
12
|
+
custom_option_2: 'asd2')
|
13
|
+
end
|
14
|
+
|
15
|
+
it "sets the 'id'" do
|
16
|
+
expect(subject.id).to eq(:a)
|
17
|
+
end
|
18
|
+
|
19
|
+
it "sets the 'name'" do
|
20
|
+
expect(subject.name).to eq('A')
|
21
|
+
end
|
22
|
+
|
23
|
+
it "sets the 'weight'" do
|
24
|
+
expect(subject.weight).to eq(0.5)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "sets the custom 'options'" do
|
28
|
+
expect(subject.options).to be_a Hash
|
29
|
+
expect(subject.options[:custom_option_1]).to eq('asd1')
|
30
|
+
expect(subject.options[:custom_option_2]).to eq('asd2')
|
31
|
+
end
|
32
|
+
|
33
|
+
describe '#marshal_dump' do
|
34
|
+
it 'can load a marshal dump correctly' do
|
35
|
+
dump = Marshal.dump(subject)
|
36
|
+
expect(dump).to be_a String
|
37
|
+
loaded_object = Marshal.load(dump)
|
38
|
+
expect(loaded_object).to_not be_a String
|
39
|
+
expect(loaded_object).to be_a EenyMeeny::Variation
|
40
|
+
expect(loaded_object.id).to eql(:a)
|
41
|
+
expect(loaded_object.name).to eq('A')
|
42
|
+
expect(loaded_object.weight).to eq(0.5)
|
43
|
+
expect(loaded_object.options[:custom_option_1]).to eq('asd1')
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class MockRackApp
|
2
|
+
|
3
|
+
attr_reader :request_body
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@request_headers = {}
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(env)
|
10
|
+
@env = env
|
11
|
+
@request_body = env['rack.input'].read
|
12
|
+
[200, {'Content-Type' => 'text/plain'}, ['OK']]
|
13
|
+
end
|
14
|
+
|
15
|
+
def [](key)
|
16
|
+
@env[key]
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'simplecov'
|
2
|
+
require 'simplecov-rcov'
|
3
|
+
require 'codeclimate-test-reporter'
|
4
|
+
|
5
|
+
SimpleCov.start do
|
6
|
+
formatter SimpleCov::Formatter::MultiFormatter[
|
7
|
+
SimpleCov::Formatter::HTMLFormatter,
|
8
|
+
SimpleCov::Formatter::RcovFormatter,
|
9
|
+
CodeClimate::TestReporter::Formatter
|
10
|
+
]
|
11
|
+
add_group('EenyMeeny', 'lib/eeny-meeny')
|
12
|
+
add_group('Specs', 'spec')
|
13
|
+
end
|
14
|
+
|
15
|
+
require 'rspec'
|
16
|
+
require 'yaml'
|
17
|
+
require 'eeny-meeny'
|
18
|
+
require 'mock_rack_app'
|
19
|
+
|
20
|
+
RSpec.configure do |config|
|
21
|
+
config.run_all_when_everything_filtered = true
|
22
|
+
config.filter_run :focus
|
23
|
+
config.order = "random"
|
24
|
+
end
|
metadata
ADDED
@@ -0,0 +1,177 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: eeny-meeny
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Christian Orthmann
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-07-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '10'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '10'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rspec
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: simplecov
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: simplecov-rcov
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: yard
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rack
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 1.2.1
|
90
|
+
- - "<"
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '2'
|
93
|
+
type: :runtime
|
94
|
+
prerelease: false
|
95
|
+
version_requirements: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: 1.2.1
|
100
|
+
- - "<"
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '2'
|
103
|
+
- !ruby/object:Gem::Dependency
|
104
|
+
name: activesupport
|
105
|
+
requirement: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - ">="
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: 3.0.0
|
110
|
+
- - "<"
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: 5.0.0
|
113
|
+
type: :runtime
|
114
|
+
prerelease: false
|
115
|
+
version_requirements: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - ">="
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: 3.0.0
|
120
|
+
- - "<"
|
121
|
+
- !ruby/object:Gem::Version
|
122
|
+
version: 5.0.0
|
123
|
+
description:
|
124
|
+
email: christian.orthmann@gmail.com
|
125
|
+
executables: []
|
126
|
+
extensions: []
|
127
|
+
extra_rdoc_files: []
|
128
|
+
files:
|
129
|
+
- CHANGELOG.md
|
130
|
+
- Gemfile
|
131
|
+
- LICENSE
|
132
|
+
- README.md
|
133
|
+
- Rakefile
|
134
|
+
- eeny-meeny.gemspec
|
135
|
+
- lib/eeny-meeny.rb
|
136
|
+
- lib/eeny-meeny/encryptor.rb
|
137
|
+
- lib/eeny-meeny/experiment.rb
|
138
|
+
- lib/eeny-meeny/experiment_helper.rb
|
139
|
+
- lib/eeny-meeny/middleware.rb
|
140
|
+
- lib/eeny-meeny/middleware_helper.rb
|
141
|
+
- lib/eeny-meeny/railtie.rb
|
142
|
+
- lib/eeny-meeny/route_constraint.rb
|
143
|
+
- lib/eeny-meeny/shared_methods.rb
|
144
|
+
- lib/eeny-meeny/variation.rb
|
145
|
+
- lib/eeny-meeny/version.rb
|
146
|
+
- spec/eeny-meeny/experiment_spec.rb
|
147
|
+
- spec/eeny-meeny/middleware_spec.rb
|
148
|
+
- spec/eeny-meeny/variation_spec.rb
|
149
|
+
- spec/fixtures/experiments.yml
|
150
|
+
- spec/mock_rack_app.rb
|
151
|
+
- spec/spec_helper.rb
|
152
|
+
homepage: http://rubygems.org/gems/eeny-meeny
|
153
|
+
licenses:
|
154
|
+
- MIT
|
155
|
+
metadata: {}
|
156
|
+
post_install_message:
|
157
|
+
rdoc_options: []
|
158
|
+
require_paths:
|
159
|
+
- lib
|
160
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
161
|
+
requirements:
|
162
|
+
- - ">="
|
163
|
+
- !ruby/object:Gem::Version
|
164
|
+
version: '0'
|
165
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
166
|
+
requirements:
|
167
|
+
- - ">="
|
168
|
+
- !ruby/object:Gem::Version
|
169
|
+
version: '0'
|
170
|
+
requirements: []
|
171
|
+
rubyforge_project:
|
172
|
+
rubygems_version: 2.4.8
|
173
|
+
signing_key:
|
174
|
+
specification_version: 4
|
175
|
+
summary: A simple split testing tool for Rails
|
176
|
+
test_files: []
|
177
|
+
has_rdoc:
|