gitlab-experiment 0.3.1 → 0.4.0

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: 4bb028b4495b7ab210c13769433a8b6ec7b8a3fc459a2c33f0e9bc650cf23916
4
- data.tar.gz: '0387ed9d7e722c4ba6bce42aed1b71720b6df23b6687100b02186f57b6f125c9'
3
+ metadata.gz: 8524dff7f908e481923e3131f3dd67c34f426e31ed10171896ea10a209e0b74a
4
+ data.tar.gz: 151f2e7f7bbf9692350c02f46f0c4c8cfa6f9c7ec05fdcb0fda50e34324c06fb
5
5
  SHA512:
6
- metadata.gz: d27ae9c243b447d994060bc2250ade8be78351312c01fa591e67441f913fd67f9e2caed1de77caac454b0a6b84702ff865e6a58c796c8e014ba2cf530dd5df9b
7
- data.tar.gz: 2a3dcfef0dbfeb7e9e35d3ca56ede0bc7d1feeeafa9774a0f9184eeb89dfe49a49e6fbc93f594332cda1d8c87b374069a0ab1c053ab51183d8fdf47af4769845
6
+ metadata.gz: 3f23698aabf3968c77f49cdb924125879663670c12d22c390a47f7c791687f87fb1211adf432777f66012e3be1db2e90d5c33a1dc0a156332bb8926938c4e587
7
+ data.tar.gz: 51973494136edef02672c9086970fec2ecb1fb3a6716edb10d1cfe758172f8d256ecd6ef1b5cefdd50133898637e022af527fc7e20df2212b1f7210db2dd087e
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
6
6
 
7
- This library provides a clean and elegant DSL to define, run, and track your GitLab experiment.
7
+ This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
8
8
 
9
9
  When we discuss the behavior of this gem, we'll use terms like experiment, context, control, candidate, and variant. It's worth defining these terms so they're more understood.
10
10
 
@@ -27,7 +27,7 @@ gem 'gitlab-experiment'
27
27
  If you're using Rails, you can install the initializer. It provides basic configuration and documentation.
28
28
 
29
29
  ```shell
30
- $ rails generate gitlab_experiment:install
30
+ $ rails generate gitlab:experiment:install
31
31
  ```
32
32
 
33
33
  ## Implementing an experiment
@@ -307,7 +307,7 @@ Gitlab::Experiment.configure do |config|
307
307
  end
308
308
  ```
309
309
 
310
- More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab_experiment/install/templates/initializer.rb).
310
+ More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb).
311
311
 
312
312
  ### Client layer / JavaScript
313
313
 
@@ -0,0 +1,17 @@
1
+ Description:
2
+ Stubs out a new experiment and its variants. Pass the experiment name,
3
+ either CamelCased or under_scored, and a list of variants as arguments.
4
+
5
+ To create an experiment within a module, specify the experiment name as a
6
+ path like 'parent_module/experiment_name'.
7
+
8
+ This generates an experiment class in app/experiments and invokes feature
9
+ flag, and test framework generators.
10
+
11
+ Example:
12
+ `rails generate gitlab:experiment NullHypothesis control candidate alt_variant`
13
+
14
+ NullHypothesis experiment with default variants.
15
+ Experiment: app/experiments/null_hypothesis_experiment.rb
16
+ Feature Flag: config/feature_flags/experiment/null_hypothesis.yaml
17
+ Test: test/experiments/null_hypothesis_experiment_test.rb
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Gitlab
6
+ module Generators
7
+ class ExperimentGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path('templates/', __dir__)
9
+ check_class_collision suffix: 'Experiment'
10
+
11
+ argument :variants,
12
+ type: :array,
13
+ default: %w[control candidate],
14
+ banner: 'variant variant'
15
+
16
+ def create_experiment
17
+ template 'experiment.rb', File.join('app/experiments', class_path, "#{file_name}_experiment.rb")
18
+ end
19
+
20
+ hook_for :test_framework
21
+
22
+ private
23
+
24
+ def file_name
25
+ @_file_name ||= remove_possible_suffix(super)
26
+ end
27
+
28
+ def remove_possible_suffix(name)
29
+ name.sub(/_?exp[ei]riment$/i, "") # be somewhat forgiving with spelling
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module Gitlab
6
+ module Generators
7
+ module Experiment
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ desc 'Installs the Gitlab::Experiment initializer and optional ApplicationExperiment into your application.'
12
+
13
+ class_option :skip_initializer,
14
+ type: :boolean,
15
+ default: false,
16
+ desc: 'Skip the initializer with default configuration'
17
+
18
+ class_option :skip_baseclass,
19
+ type: :boolean,
20
+ default: false,
21
+ desc: 'Skip the ApplicationExperiment base class'
22
+
23
+ def create_initializer
24
+ return if options[:skip_initializer]
25
+
26
+ template 'initializer.rb', 'config/initializers/gitlab_experiment.rb'
27
+ end
28
+
29
+ def create_baseclass
30
+ return if options[:skip_baseclass]
31
+
32
+ template 'application_experiment.rb', 'app/experiments/application_experiment.rb'
33
+ end
34
+
35
+ def display_post_install
36
+ readme 'POST_INSTALL' if behavior == :invoke
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,2 @@
1
+ Gitlab::Experiment has been installed. You may want to adjust the configuration
2
+ that's been provided in the Rails initializer.
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationExperiment < Gitlab::Experiment
4
+ end
@@ -7,6 +7,9 @@ Gitlab::Experiment.configure do |config|
7
7
  # The logger is used to log various details of the experiments.
8
8
  config.logger = Logger.new($stdout)
9
9
 
10
+ # The base class that should be instantiated for basic experiments.
11
+ config.base_class = 'ApplicationExperiment'
12
+
10
13
  # The caching layer is expected to respond to fetch, like Rails.cache.
11
14
  config.cache = nil
12
15
 
@@ -84,4 +87,10 @@ Gitlab::Experiment.configure do |config|
84
87
  values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
85
88
  Digest::MD5.hexdigest((context.keys + values).join('|'))
86
89
  end
90
+
91
+ # The domain for which this cookie applies so you can restrict to the domain level.
92
+ #
93
+ # When not set, it uses the current host. If you want to provide specific hosts, you can
94
+ # provide them either via an array like `['www.gitlab.com', .gitlab.com']`, or set it to `:all`.
95
+ config.cookie_domain = :all
87
96
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% if namespaced? -%>
4
+ require_dependency "<%= namespaced_path %>/application_experiment"
5
+
6
+ <% end -%>
7
+ <% module_namespacing do -%>
8
+ class <%= class_name %>Experiment < ApplicationExperiment
9
+ <% variants.each do |variant| -%>
10
+ def <%= variant %>_behavior
11
+ end
12
+ <%= "\n" unless variant == variants.last -%>
13
+ <% end -%>
14
+ end
15
+ <% end -%>
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'generators/rspec'
4
+
5
+ module Rspec
6
+ module Generators
7
+ class ExperimentGenerator < Rspec::Generators::Base
8
+ source_root File.expand_path('templates/', __dir__)
9
+
10
+ def create_experiment_spec
11
+ template 'experiment_spec.rb', File.join('spec/experiments', class_path, "#{file_name}_experiment_spec.rb")
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+
5
+ <% module_namespacing do -%>
6
+ RSpec.describe <%= class_name %>Experiment do
7
+ pending "add some examples to (or delete) #{__FILE__}"
8
+ end
9
+ <% end -%>
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/test_unit'
4
+
5
+ module TestUnit # :nodoc:
6
+ module Generators # :nodoc:
7
+ class ExperimentGenerator < TestUnit::Generators::Base # :nodoc:
8
+ source_root File.expand_path('templates/', __dir__)
9
+
10
+ check_class_collision suffix: 'Test'
11
+
12
+ def create_test_file
13
+ template 'experiment_test.rb', File.join('test/experiments', class_path, "#{file_name}_experiment_test.rb")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>ExperimentTest < ActiveSupport::TestCase
7
+ # test "the truth" do
8
+ # assert true
9
+ # end
10
+ end
11
+ <% end -%>
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'scientist'
4
+ require 'active_support/callbacks'
5
+ require 'active_support/core_ext/object/blank'
6
+ require 'active_support/core_ext/string/inflections'
4
7
 
5
8
  require 'gitlab/experiment/caching'
9
+ require 'gitlab/experiment/callbacks'
6
10
  require 'gitlab/experiment/configuration'
7
11
  require 'gitlab/experiment/cookies'
8
12
  require 'gitlab/experiment/context'
@@ -15,28 +19,45 @@ module Gitlab
15
19
  class Experiment
16
20
  include Scientist::Experiment
17
21
  include Caching
22
+ include Callbacks
18
23
 
19
24
  class << self
20
25
  def configure
21
26
  yield Configuration
22
27
  end
23
28
 
24
- def run(name, variant_name = nil, **context, &block)
29
+ def run(name = nil, variant_name = nil, **context, &block)
30
+ raise ArgumentError, 'name is required' if name.nil? && base?
31
+
25
32
  instance = constantize(name).new(name, variant_name, **context, &block)
26
33
  return instance unless block_given?
27
34
 
28
35
  instance.context.frozen? ? instance.run : instance.tap(&:run)
29
36
  end
30
37
 
31
- def constantize(name)
32
- name = "#{name}_experiment"
33
- klass = name.respond_to?(:classify) ? name.classify.safe_constantize : nil
34
- klass || self
38
+ def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
39
+ name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
40
+ name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
41
+ suffix ? name : name.sub(/_#{suffix_word}$/, '')
42
+ end
43
+
44
+ def base?
45
+ self == Gitlab::Experiment || name == Configuration.base_class
46
+ end
47
+
48
+ private
49
+
50
+ def constantize(name = nil)
51
+ return self if name.nil?
52
+
53
+ experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
35
54
  end
36
55
  end
37
56
 
38
- def initialize(name, variant_name = nil, **context)
39
- @name = name
57
+ def initialize(name = nil, variant_name = nil, **context)
58
+ raise ArgumentError, 'name is required' if name.blank? && self.class.base?
59
+
60
+ @name = self.class.experiment_name(name, suffix: false)
40
61
  @variant_name = variant_name
41
62
  @excluded = []
42
63
  @context = Context.new(self, context)
@@ -48,7 +69,7 @@ module Gitlab
48
69
  end
49
70
 
50
71
  def context(value = nil)
51
- return @context if value.nil?
72
+ return @context if value.blank?
52
73
 
53
74
  @context.value(value)
54
75
  @context
@@ -70,7 +91,17 @@ module Gitlab
70
91
  @variant_name = variant_name unless variant_name.nil?
71
92
  @variant_name ||= :control if excluded?
72
93
 
73
- super(cache { variant.name })
94
+ chain = variant_assigned? ? :unsegmented_run : :segmented_run
95
+ run_callbacks(chain) do
96
+ variant_name = cache { variant.name }
97
+
98
+ method_name = "#{variant_name}_behavior"
99
+ if respond_to?(method_name)
100
+ behaviors[variant_name] ||= -> { send(method_name) } # rubocop:disable GitlabSecurity/PublicSend
101
+ end
102
+
103
+ super(variant_name)
104
+ end
74
105
  end
75
106
  end
76
107
 
@@ -104,6 +135,10 @@ module Gitlab
104
135
  @excluded.any? { |exclude| exclude.call(self) }
105
136
  end
106
137
 
138
+ def variant_assigned?
139
+ !@variant_name.nil?
140
+ end
141
+
107
142
  def id
108
143
  "#{name}:#{signature[:key]}"
109
144
  end
@@ -113,6 +148,10 @@ module Gitlab
113
148
  "Experiment;#{id}"
114
149
  end
115
150
 
151
+ def key_for(hash)
152
+ instance_exec(hash, &Configuration.context_hash_strategy)
153
+ end
154
+
116
155
  protected
117
156
 
118
157
  def generate_result(variant_name)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ class Experiment
5
+ module Callbacks
6
+ extend ActiveSupport::Concern
7
+ include ActiveSupport::Callbacks
8
+
9
+ included do
10
+ define_callbacks(
11
+ :unsegmented_run,
12
+ skip_after_callbacks_if_terminated: true
13
+ )
14
+
15
+ define_callbacks(
16
+ :segmented_run,
17
+ skip_after_callbacks_if_terminated: false,
18
+ terminator: lambda do |target, result_lambda|
19
+ result_lambda.call
20
+ target.variant_assigned?
21
+ end
22
+ )
23
+ end
24
+
25
+ class_methods do
26
+ def segment(*filter_list, variant:, **options, &block)
27
+ filters = filter_list.unshift(block).compact.map do |filter|
28
+ result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
29
+ ->(target) { target.variant(variant) if result_lambda.call(target, nil) }
30
+ end
31
+
32
+ raise ArgumentError, 'no filters provided' if filters.empty?
33
+
34
+ set_callback(:segmented_run, :before, *filters, options, &block)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -15,6 +15,9 @@ module Gitlab
15
15
  # The logger is used to log various details of the experiments.
16
16
  @logger = Logger.new($stdout)
17
17
 
18
+ # The base class that should be instantiated for basic experiments.
19
+ @base_class = 'Gitlab::Experiment'
20
+
18
21
  # Cache layer. Expected to respond to fetch, like Rails.cache.
19
22
  @cache = nil
20
23
 
@@ -35,20 +38,27 @@ module Gitlab
35
38
  end
36
39
 
37
40
  # Algorithm that consistently generates a hash key for a given hash map.
38
- @context_hash_strategy = lambda do |context|
39
- values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
40
- Digest::MD5.hexdigest((context.keys + values).join('|'))
41
+ @context_hash_strategy = lambda do |hash_map|
42
+ values = hash_map.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
43
+ Digest::MD5.hexdigest(([name] + hash_map.keys + values).join('|'))
41
44
  end
42
45
 
46
+ # The domain for which this cookie applies so you can restrict to the domain level.
47
+ # When not set, it uses the current host. If you want to provide specific hosts, you can
48
+ # provide them either via an array like `['www.gitlab.com', .gitlab.com']`, or set it to `:all`.
49
+ @cookie_domain = :all
50
+
43
51
  class << self
44
52
  attr_accessor(
45
53
  :name_prefix,
46
54
  :logger,
55
+ :base_class,
47
56
  :cache,
48
57
  :variant_resolver,
49
58
  :tracking_behavior,
50
59
  :publishing_behavior,
51
- :context_hash_strategy
60
+ :context_hash_strategy,
61
+ :cookie_domain
52
62
  )
53
63
  end
54
64
  end
@@ -39,7 +39,7 @@ module Gitlab
39
39
  end
40
40
 
41
41
  def signature
42
- @signature ||= { key: key_for(@value), migration_keys: migration_keys }.compact
42
+ @signature ||= { key: @experiment.key_for(@value), migration_keys: migration_keys }.compact
43
43
  end
44
44
 
45
45
  private
@@ -60,12 +60,8 @@ module Gitlab
60
60
  def migration_keys
61
61
  return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
62
62
 
63
- @migrations[:unmerged].map { |m| key_for(m) } +
64
- @migrations[:merged].map { |m| key_for(@value.merge(m)) }
65
- end
66
-
67
- def key_for(context)
68
- Configuration.context_hash_strategy.call(context)
63
+ @migrations[:unmerged].map { |m| @experiment.key_for(m) } +
64
+ @migrations[:merged].map { |m| @experiment.key_for(@value.merge(m)) }
69
65
  end
70
66
  end
71
67
  end
@@ -24,7 +24,7 @@ module Gitlab
24
24
  return hash.merge(key => cookie) if hash[key].nil?
25
25
 
26
26
  add_migration(key => cookie)
27
- cookie_jar.delete(cookie_name, domain: :all)
27
+ cookie_jar.delete(cookie_name, domain: domain)
28
28
 
29
29
  hash
30
30
  end
@@ -34,11 +34,15 @@ module Gitlab
34
34
 
35
35
  cookie ||= SecureRandom.uuid
36
36
  cookie_jar.permanent.signed[cookie_name] = {
37
- value: cookie, secure: true, domain: :all, httponly: true
37
+ value: cookie, secure: true, domain: domain, httponly: true
38
38
  }
39
39
 
40
40
  hash.merge(key => cookie)
41
41
  end
42
+
43
+ def domain
44
+ Configuration.cookie_domain
45
+ end
42
46
  end
43
47
  end
44
48
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.3.1'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - GitLab
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-10 00:00:00.000000000 Z
11
+ date: 2020-11-17 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: scientist
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -39,11 +53,20 @@ extra_rdoc_files: []
39
53
  files:
40
54
  - LICENSE.txt
41
55
  - README.md
42
- - lib/generators/gitlab_experiment/install/POST_INSTALL
43
- - lib/generators/gitlab_experiment/install/install_generator.rb
44
- - lib/generators/gitlab_experiment/install/templates/initializer.rb
56
+ - lib/generators/gitlab/experiment/USAGE
57
+ - lib/generators/gitlab/experiment/experiment_generator.rb
58
+ - lib/generators/gitlab/experiment/install/install_generator.rb
59
+ - lib/generators/gitlab/experiment/install/templates/POST_INSTALL
60
+ - lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt
61
+ - lib/generators/gitlab/experiment/install/templates/initializer.rb.tt
62
+ - lib/generators/gitlab/experiment/templates/experiment.rb.tt
63
+ - lib/generators/rspec/experiment/experiment_generator.rb
64
+ - lib/generators/rspec/experiment/templates/experiment_spec.rb.tt
65
+ - lib/generators/test_unit/experiment/experiment_generator.rb
66
+ - lib/generators/test_unit/experiment/templates/experiment_test.rb.tt
45
67
  - lib/gitlab/experiment.rb
46
68
  - lib/gitlab/experiment/caching.rb
69
+ - lib/gitlab/experiment/callbacks.rb
47
70
  - lib/gitlab/experiment/configuration.rb
48
71
  - lib/gitlab/experiment/context.rb
49
72
  - lib/gitlab/experiment/cookies.rb
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rails/generators'
4
-
5
- module GitlabExperiment
6
- module Generators
7
- class InstallGenerator < Rails::Generators::Base
8
- source_root File.expand_path(__dir__)
9
-
10
- desc 'Installs the Gitlab Experiment initializer into your application.'
11
-
12
- def copy_initializers
13
- copy_file 'templates/initializer.rb', 'config/initializers/gitlab_experiment.rb'
14
- end
15
-
16
- def display_post_install
17
- readme 'POST_INSTALL' if behavior == :invoke
18
- end
19
- end
20
- end
21
- end