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.
- checksums.yaml +4 -4
- data/README.md +218 -82
- data/lib/generators/gitlab/experiment/USAGE +17 -0
- data/lib/generators/gitlab/experiment/experiment_generator.rb +33 -0
- data/lib/generators/gitlab/experiment/install/install_generator.rb +41 -0
- data/lib/generators/gitlab/experiment/install/templates/POST_INSTALL +2 -0
- data/lib/generators/gitlab/experiment/install/templates/application_experiment.rb.tt +4 -0
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +96 -0
- data/lib/generators/gitlab/experiment/templates/experiment.rb.tt +15 -0
- data/lib/generators/rspec/experiment/experiment_generator.rb +15 -0
- data/lib/generators/rspec/experiment/templates/experiment_spec.rb.tt +9 -0
- data/lib/generators/test_unit/experiment/experiment_generator.rb +17 -0
- data/lib/generators/test_unit/experiment/templates/experiment_test.rb.tt +11 -0
- data/lib/gitlab/experiment.rb +88 -14
- data/lib/gitlab/experiment/caching.rb +33 -0
- data/lib/gitlab/experiment/callbacks.rb +39 -0
- data/lib/gitlab/experiment/configuration.rb +29 -9
- data/lib/gitlab/experiment/context.rb +29 -46
- data/lib/gitlab/experiment/cookies.rb +48 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +36 -11
- data/lib/generators/gitlab_experiment/install/POST_INSTALL +0 -0
- data/lib/generators/gitlab_experiment/install/install_generator.rb +0 -21
- data/lib/generators/gitlab_experiment/install/templates/initializer.rb +0 -77
@@ -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(
|
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 |
|
25
|
-
Configuration.logger.info "Gitlab::Experiment[#{name}] #{
|
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)
|
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 |
|
36
|
-
values =
|
37
|
-
Digest::MD5.hexdigest((
|
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
|
42
|
-
|
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
|
-
@
|
12
|
-
|
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
|
-
|
27
|
+
reinitialize(value.delete(:request))
|
20
28
|
|
21
|
-
@
|
22
|
-
|
23
|
-
|
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 #
|
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
|
42
|
-
|
43
|
-
|
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
|
-
|
52
|
-
@cookie_name ||= [@experiment.name, 'id'].join('_')
|
51
|
+
migrate_cookie(value, "#{@experiment.name}_id")
|
53
52
|
end
|
54
53
|
|
55
|
-
def
|
56
|
-
return
|
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
|
-
|
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 @
|
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
|
-
|
81
|
-
|
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
|
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.
|
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
|
+
date: 2020-11-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: activesupport
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
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/
|
43
|
-
- lib/generators/
|
44
|
-
- lib/generators/
|
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.
|
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.
|
File without changes
|
@@ -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
|