gitlab-experiment 0.2.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -13,7 +13,13 @@ module Gitlab
13
13
  @name_prefix = nil
14
14
 
15
15
  # The logger is used to log various details of the experiments.
16
- @logger = Logger.new(STDOUT)
16
+ @logger = Logger.new($stdout)
17
+
18
+ # The base class that should be instantiated for basic experiments.
19
+ @base_class = 'Gitlab::Experiment'
20
+
21
+ # Cache layer. Expected to respond to fetch, like Rails.cache.
22
+ @cache = nil
17
23
 
18
24
  # Logic this project uses to resolve a variant for a given experiment.
19
25
  @variant_resolver = lambda do |requested_variant|
@@ -21,25 +27,39 @@ module Gitlab
21
27
  end
22
28
 
23
29
  # Tracking behavior can be implemented to link an event to an experiment.
24
- @tracking_behavior = lambda do |action, event_args|
25
- Configuration.logger.info "Gitlab::Experiment[#{name}] #{action}: #{event_args.merge(signature: signature)}"
30
+ @tracking_behavior = lambda do |event, args|
31
+ Configuration.logger.info "Gitlab::Experiment[#{name}] #{event}: #{args.merge(signature: signature)}"
26
32
  end
27
33
 
28
34
  # Called at the end of every experiment run, with the results. You may
29
35
  # want to push the experiment into the client or push results elsewhere.
30
36
  @publishing_behavior = lambda do |_result|
31
- track(:assignment) # this will call the config.tracking_behavior
37
+ track(:assignment)
32
38
  end
33
39
 
34
40
  # Algorithm that consistently generates a hash key for a given hash map.
35
- @context_hash_strategy = lambda do |context|
36
- values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
37
- 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('|'))
38
44
  end
39
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
+
40
51
  class << self
41
- attr_accessor :name_prefix, :logger
42
- attr_accessor :variant_resolver, :tracking_behavior, :publishing_behavior, :context_hash_strategy
52
+ attr_accessor(
53
+ :name_prefix,
54
+ :logger,
55
+ :base_class,
56
+ :cache,
57
+ :variant_resolver,
58
+ :tracking_behavior,
59
+ :publishing_behavior,
60
+ :context_hash_strategy,
61
+ :cookie_domain
62
+ )
43
63
  end
44
64
  end
45
65
  end
@@ -3,82 +3,65 @@
3
3
  module Gitlab
4
4
  class Experiment
5
5
  class Context
6
+ include Cookies
7
+
6
8
  DNT_REGEXP = /^(true|t|yes|y|1|on)$/i.freeze
7
9
 
8
- def initialize(experiment)
10
+ def initialize(experiment, **initial_value)
9
11
  @experiment = experiment
10
12
  @value = {}
11
- @migrations_from = []
12
- @migrations_with = []
13
+ @migrations = { merged: [], unmerged: [] }
14
+
15
+ value(initial_value)
16
+ end
17
+
18
+ def reinitialize(request)
19
+ @signature = nil # clear memoization
20
+ @request = request if request.respond_to?(:headers) && request.respond_to?(:cookie_jar)
13
21
  end
14
22
 
15
23
  def value(value = nil)
16
24
  return @value if value.nil?
17
25
 
18
26
  value = value.dup # dup so we don't mutate
19
- @signature = nil # clear memoized signature
27
+ reinitialize(value.delete(:request))
20
28
 
21
- @migrations_from << value.delete(:migrated_from) if value[:migrated_from]
22
- @migrations_with << value.delete(:migrated_with) if value[:migrated_with]
23
- @value.merge!(migrate_cookie_to_user_id(value))
29
+ @value.merge!(process_migrations(value))
30
+ end
31
+
32
+ def trackable?
33
+ !(@request && @request.headers['DNT'].to_s.match?(DNT_REGEXP))
24
34
  end
25
35
 
26
36
  def freeze
27
- signature # ensure we're done before being frozen
37
+ signature # finalize before freezing
28
38
  super
29
39
  end
30
40
 
31
41
  def signature
32
- @signature ||= {
33
- key: key_for(@value),
34
- migration_keys: migration_keys,
35
- variant: @experiment.variant.name
36
- }.compact
42
+ @signature ||= { key: @experiment.key_for(@value), migration_keys: migration_keys }.compact
37
43
  end
38
44
 
39
45
  private
40
46
 
41
- def migrate_cookie_to_user_id(hash)
42
- return hash unless (request = hash.delete(:request))
43
- return hash unless request.respond_to?(:headers) && request.respond_to?(:cookie_jar)
44
- return hash if request.headers['DNT'].to_s.match?(DNT_REGEXP)
45
-
46
- jar = request.cookie_jar
47
- resolver = [jar, hash, :user_id, jar.signed[cookie_name]].compact
48
- resolve_cookie(*resolver) or generate_cookie(*resolver)
49
- end
47
+ def process_migrations(value)
48
+ add_migration(value.delete(:migrated_from))
49
+ add_migration(value.delete(:migrated_with), merge: true)
50
50
 
51
- def cookie_name
52
- @cookie_name ||= [@experiment.name, 'id'].join('_')
51
+ migrate_cookie(value, "#{@experiment.name}_id")
53
52
  end
54
53
 
55
- def resolve_cookie(jar, hash, key, cookie = nil)
56
- return if cookie.blank? && hash[key].blank?
57
- return hash.merge(key => cookie) if hash[key].blank?
58
-
59
- @migrations_with << { user_id: cookie }
60
- jar.delete(cookie_name, domain: :all)
54
+ def add_migration(value, merge: false)
55
+ return unless value.is_a?(Hash)
61
56
 
62
- hash
63
- end
64
-
65
- def generate_cookie(jar, hash, key, cookie = SecureRandom.uuid)
66
- jar.permanent.signed[cookie_name] = {
67
- value: cookie, secure: true, domain: :all, httponly: true
68
- }
69
-
70
- hash.merge(key => cookie)
57
+ @migrations[merge ? :merged : :unmerged] << value
71
58
  end
72
59
 
73
60
  def migration_keys
74
- return nil if @migrations_from.empty? && @migrations_with.empty?
75
-
76
- @migrations_from.map { |m| key_for(m) } +
77
- @migrations_with.map { |m| key_for(@value.merge(m)) }
78
- end
61
+ return nil if @migrations[:unmerged].empty? && @migrations[:merged].empty?
79
62
 
80
- def key_for(context)
81
- 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)) }
82
65
  end
83
66
  end
84
67
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Gitlab
6
+ class Experiment
7
+ module Cookies
8
+ private
9
+
10
+ def migrate_cookie(hash, cookie_name)
11
+ return hash if cookie_jar.nil?
12
+
13
+ resolver = [hash, :actor, cookie_name, cookie_jar.signed[cookie_name]]
14
+ resolve_cookie(*resolver) or generate_cookie(*resolver)
15
+ end
16
+
17
+ def cookie_jar
18
+ @request&.cookie_jar
19
+ end
20
+
21
+ def resolve_cookie(hash, key, cookie_name, cookie)
22
+ return if cookie.to_s.empty? && hash[key].nil?
23
+ return hash if cookie.to_s.empty?
24
+ return hash.merge(key => cookie) if hash[key].nil?
25
+
26
+ add_migration(key => cookie)
27
+ cookie_jar.delete(cookie_name, domain: domain)
28
+
29
+ hash
30
+ end
31
+
32
+ def generate_cookie(hash, key, cookie_name, cookie)
33
+ return hash unless hash.key?(key)
34
+
35
+ cookie ||= SecureRandom.uuid
36
+ cookie_jar.permanent.signed[cookie_name] = {
37
+ value: cookie, secure: true, domain: domain, httponly: true
38
+ }
39
+
40
+ hash.merge(key => cookie)
41
+ end
42
+
43
+ def domain
44
+ Configuration.cookie_domain
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  class Experiment
5
- VERSION = '0.2.2'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,35 +1,49 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-experiment
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
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-09-24 00:00:00.000000000 Z
11
+ date: 2020-11-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: scientist
14
+ name: activesupport
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 1.5.0
20
- - - "~>"
21
- - !ruby/object:Gem::Version
22
- version: '1.5'
19
+ version: '3.0'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
26
23
  requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: scientist
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.5'
27
34
  - - ">="
28
35
  - !ruby/object:Gem::Version
29
36
  version: 1.5.0
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
30
41
  - - "~>"
31
42
  - !ruby/object:Gem::Version
32
43
  version: '1.5'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.5.0
33
47
  description:
34
48
  email:
35
49
  - gitlab_rubygems@gitlab.com
@@ -39,12 +53,23 @@ 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
68
+ - lib/gitlab/experiment/caching.rb
69
+ - lib/gitlab/experiment/callbacks.rb
46
70
  - lib/gitlab/experiment/configuration.rb
47
71
  - lib/gitlab/experiment/context.rb
72
+ - lib/gitlab/experiment/cookies.rb
48
73
  - lib/gitlab/experiment/dsl.rb
49
74
  - lib/gitlab/experiment/engine.rb
50
75
  - lib/gitlab/experiment/variant.rb
@@ -68,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
93
  - !ruby/object:Gem::Version
69
94
  version: '0'
70
95
  requirements: []
71
- rubygems_version: 3.0.3
96
+ rubygems_version: 3.1.4
72
97
  signing_key:
73
98
  specification_version: 4
74
99
  summary: GitLab experiment library built on top of scientist.
@@ -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
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Gitlab::Experiment.configure do |config|
4
- # Prefix all experiment names with a given value. Use `nil` for none.
5
- config.name_prefix = nil
6
-
7
- # The logger is used to log various details of the experiments.
8
- config.logger = Logger.new(STDOUT)
9
-
10
- # Logic this project uses to resolve a variant for a given experiment.
11
- #
12
- # This can return an instance of any object that responds to `name`, or can
13
- # return a variant name as a string, in which case the build in variant
14
- # class will be used.
15
- #
16
- # This block will be executed within the scope of the experiment instance,
17
- # so can easily access experiment methods, like getting the name or context.
18
- config.variant_resolver = lambda do |requested_variant|
19
- # An example of running the control, unless a variant was requested:
20
- requested_variant || 'control'
21
-
22
- # Always run candidate, unless a variant was requested, with fallback:
23
- # variant_name || variant_names.first || 'control'
24
-
25
- # Using unleash to determine the variant:
26
- # TODO: this isn't entirely accurate.
27
- # fallback = Unleash::Variant.new(name: requested_variant || 'control', enabled: true)
28
- # UNLEASH.get_variant(name, context.value, fallback)
29
-
30
- # Using Flipper to determine the variant:
31
- # TODO: provide example.
32
- # Variant.new(name: resolved)
33
- end
34
-
35
- # Tracking behavior can be implemented to link an event to an experiment.
36
- #
37
- # Similar to the variant resolver, this is called within the scope of the
38
- # experiment instance and so can access any methods on the experiment.
39
- config.tracking_behavior = lambda do |action, event_args|
40
- # An example of using a generic logger to track events:
41
- (event_args[:context] ||= []) << context.signature.merge(group: variant.name)
42
- config.logger.info "Gitlab::Experiment[#{name}] #{action}: #{event_args}"
43
-
44
- # Using something like snowplow to track events:
45
- # (event_args[:context] ||= []) << SnowplowTracker::SelfDescribingJson.new(
46
- # 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
47
- # experiment: context.signature.merge(group: variant.name)
48
- # )
49
- #
50
- # Tracking.event(name, action, **event_args)
51
- end
52
-
53
- # Called at the end of every experiment run, with the result.
54
- #
55
- # You may want to track that you've assigned a variant to a given context,
56
- # or push the experiment into the client or publish results elsewhere, like
57
- # into redis.
58
- config.publishing_behavior = lambda do |_result|
59
- track(:assignment) # this will call the config.tracking_behavior
60
-
61
- # Log the results, so we can inspect them if we wanted to later.
62
- # LogRage.log(result: _result)
63
-
64
- # Push the experiment knowledge into the client using Gon.
65
- # Gon.push(experiment: { name => signature })
66
- end
67
-
68
- # Algorithm that consistently generates a hash key for a given hash map.
69
- #
70
- # Given a specific context hash map, we need to generate a consistent hash
71
- # key. The logic in here will be used for generating cache keys, and may also
72
- # be used when determining which variant may be presented.
73
- config.context_hash_strategy = lambda do |context|
74
- values = context.values.map { |v| (v.respond_to?(:to_global_id) ? v.to_global_id : v).to_s }
75
- Digest::MD5.hexdigest((context.keys + values).join('|'))
76
- end
77
- end