gitlab-experiment 0.2.2 → 0.4.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.
@@ -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