summaryrefslogtreecommitdiff
path: root/app/experiments
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-02-18 10:34:06 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-02-18 10:34:06 +0000
commit859a6fb938bb9ee2a317c46dfa4fcc1af49608f0 (patch)
treed7f2700abe6b4ffcb2dcfc80631b2d87d0609239 /app/experiments
parent446d496a6d000c73a304be52587cd9bbc7493136 (diff)
downloadgitlab-ce-859a6fb938bb9ee2a317c46dfa4fcc1af49608f0.tar.gz
Add latest changes from gitlab-org/gitlab@13-9-stable-eev13.9.0-rc42
Diffstat (limited to 'app/experiments')
-rw-r--r--app/experiments/application_experiment.rb45
-rw-r--r--app/experiments/members/invite_email_experiment.rb18
-rw-r--r--app/experiments/new_project_readme_experiment.rb45
-rw-r--r--app/experiments/strategy/round_robin.rb78
4 files changed, 181 insertions, 5 deletions
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
index 7a8851d11ce..317514d088b 100644
--- a/app/experiments/application_experiment.rb
+++ b/app/experiments/application_experiment.rb
@@ -1,13 +1,20 @@
# frozen_string_literal: true
-class ApplicationExperiment < Gitlab::Experiment
+class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/NamespacedClass
+ def enabled?
+ return false if Feature::Definition.get(feature_flag_name).nil? # there has to be a feature flag yaml file
+ return false unless Gitlab.dev_env_or_com? # we're in an environment that allows experiments
+
+ Feature.get(feature_flag_name).state != :off # rubocop:disable Gitlab/AvoidFeatureGet
+ end
+
def publish(_result)
track(:assignment) # track that we've assigned a variant for this context
Gon.global.push({ experiment: { name => signature } }, true) # push to client
end
def track(action, **event_args)
- return if excluded? # no events for opted out actors or excluded subjects
+ return unless should_track? # no events for opted out actors or excluded subjects
Gitlab::Tracking.event(name, action.to_s, **event_args.merge(
context: (event_args[:context] || []) << SnowplowTracker::SelfDescribingJson.new(
@@ -16,10 +23,39 @@ class ApplicationExperiment < Gitlab::Experiment
))
end
+ def rollout_strategy
+ # no-op override in inherited class as desired
+ end
+
+ def variants
+ # override as desired in inherited class with all variants + control
+ # %i[variant1 variant2 control]
+ #
+ # this will make sure we supply variants as these go together - rollout_strategy of :round_robin must have variants
+ raise NotImplementedError, "Inheriting class must supply variants as an array if :round_robin strategy is used" if rollout_strategy == :round_robin
+ end
+
private
+ def feature_flag_name
+ name.tr('/', '_')
+ end
+
def resolve_variant_name
- return variant_names.first if Feature.enabled?(name, self, type: :experiment)
+ case rollout_strategy
+ when :round_robin
+ round_robin_rollout
+ else
+ percentage_rollout
+ end
+ end
+
+ def round_robin_rollout
+ Strategy::RoundRobin.new(feature_flag_name, variants).execute
+ end
+
+ def percentage_rollout
+ return variant_names.first if Feature.enabled?(feature_flag_name, self, type: :experiment, default_enabled: :yaml)
nil # Returning nil vs. :control is important for not caching and rollouts.
end
@@ -41,7 +77,7 @@ class ApplicationExperiment < Gitlab::Experiment
# default cache key strategy. So running `cache.fetch("foo:bar", "value")`
# would create/update a hash with the key of "foo", with a field named
# "bar" that has "value" assigned to it.
- class Cache < ActiveSupport::Cache::Store
+ class Cache < ActiveSupport::Cache::Store # rubocop:disable Gitlab/NamespacedClass
# Clears the entire cache for a given experiment. Be careful with this
# since it would reset all resolved variants for the entire experiment.
def clear(key:)
@@ -72,7 +108,6 @@ class ApplicationExperiment < Gitlab::Experiment
end
def write_entry(key, entry, **options)
- return false unless Feature.enabled?(:caching_experiments)
return false if entry.value.blank? # don't cache any empty values
pool { |redis| redis.hset(*hkey(key), entry.value) }
diff --git a/app/experiments/members/invite_email_experiment.rb b/app/experiments/members/invite_email_experiment.rb
new file mode 100644
index 00000000000..4a03ebb7726
--- /dev/null
+++ b/app/experiments/members/invite_email_experiment.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Members
+ class InviteEmailExperiment < ApplicationExperiment
+ exclude { context.actor.created_by.blank? }
+ exclude { context.actor.created_by.avatar_url.nil? }
+
+ INVITE_TYPE = 'initial_email'
+
+ def rollout_strategy
+ :round_robin
+ end
+
+ def variants
+ %i[avatar permission_info control]
+ end
+ end
+end
diff --git a/app/experiments/new_project_readme_experiment.rb b/app/experiments/new_project_readme_experiment.rb
new file mode 100644
index 00000000000..8f88ad2adc1
--- /dev/null
+++ b/app/experiments/new_project_readme_experiment.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class NewProjectReadmeExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+ include Gitlab::Git::WrapsGitalyErrors
+
+ INITIAL_WRITE_LIMIT = 3
+ EXPERIMENT_START_DATE = DateTime.parse('2021/1/20')
+ MAX_ACCOUNT_AGE = 7.days
+
+ exclude { context.value[:actor].nil? }
+ exclude { context.actor.created_at < MAX_ACCOUNT_AGE.ago }
+
+ def control_behavior
+ false # we don't want the checkbox to be checked
+ end
+
+ def candidate_behavior
+ true # check the checkbox by default
+ end
+
+ def track_initial_writes(project)
+ return unless should_track? # early return if we don't need to ask for commit counts
+ return unless project.created_at > EXPERIMENT_START_DATE # early return for older projects
+ return unless (commit_count = commit_count_for(project)) < INITIAL_WRITE_LIMIT
+
+ track(:write, property: project.created_at.to_s, value: commit_count)
+ end
+
+ private
+
+ def commit_count_for(project)
+ raw_repo = project.repository&.raw_repository
+ return INITIAL_WRITE_LIMIT unless raw_repo&.root_ref
+
+ begin
+ Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(raw_repo.root_ref, {
+ all: true, # include all branches
+ max_count: INITIAL_WRITE_LIMIT # limit as an optimization
+ })
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, experiment: name)
+ INITIAL_WRITE_LIMIT
+ end
+ end
+end
diff --git a/app/experiments/strategy/round_robin.rb b/app/experiments/strategy/round_robin.rb
new file mode 100644
index 00000000000..7b80c0e984d
--- /dev/null
+++ b/app/experiments/strategy/round_robin.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Strategy
+ class RoundRobin
+ CacheError = Class.new(StandardError)
+
+ COUNTER_EXPIRE_TIME = 86400 # one day
+
+ def initialize(key, variants)
+ @key = key
+ @variants = variants
+ end
+
+ def execute
+ increment_counter
+ resolve_variant_name
+ end
+
+ # When the counter would expire
+ #
+ # @api private Used internally by SRE and debugging purpose
+ # @return [Integer] Number in seconds until expiration or false if never
+ def counter_expires_in
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.ttl(key)
+ end
+ end
+
+ # Return the actual counter value
+ #
+ # @return [Integer] value
+ def counter_value
+ Gitlab::Redis::SharedState.with do |redis|
+ (redis.get(key) || 0).to_i
+ end
+ end
+
+ # Reset the counter
+ #
+ # @private Used internally by SRE and debugging purpose
+ # @return [Boolean] whether reset was a success
+ def reset!
+ redis_cmd do |redis|
+ redis.del(key)
+ end
+ end
+
+ private
+
+ attr_reader :key, :variants
+
+ # Increase the counter
+ #
+ # @return [Boolean] whether operation was a success
+ def increment_counter
+ redis_cmd do |redis|
+ redis.incr(key)
+ redis.expire(key, COUNTER_EXPIRE_TIME)
+ end
+ end
+
+ def resolve_variant_name
+ remainder = counter_value % variants.size
+
+ variants[remainder]
+ end
+
+ def redis_cmd
+ Gitlab::Redis::SharedState.with { |redis| yield(redis) }
+
+ true
+ rescue CacheError => e
+ Gitlab::AppLogger.warn("GitLab: An unexpected error occurred in writing to Redis: #{e}")
+
+ false
+ end
+ end
+end