summaryrefslogtreecommitdiff
path: root/app/experiments/application_experiment.rb
blob: 317514d088b8ef95d5d2a989c4b7fa94a94eb20d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# frozen_string_literal: true

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 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(
        'iglu:com.gitlab/gitlab_experiment/jsonschema/0-3-0', signature
      )
    ))
  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
    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

  # Cache is an implementation on top of Gitlab::Redis::SharedState that also
  # adheres to the ActiveSupport::Cache::Store interface and uses the redis
  # hash data type.
  #
  # Since Gitlab::Experiment can use any type of caching layer, utilizing the
  # long lived shared state interface here gives us an efficient way to store
  # context keys and the variant they've been assigned -- while also giving us
  # a simple way to clean up an experiments data upon resolution.
  #
  # The data structure:
  #   key: experiment.name
  #   fields: context key => variant name
  #
  # The keys are expected to be `experiment_name:context_key`, which is the
  # 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 # 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:)
      key = hkey(key)[0] # extract only the first part of the key
      pool do |redis|
        case redis.type(key)
        when 'hash', 'none' then redis.del(key)
        else raise ArgumentError, 'invalid call to clear a non-hash cache key'
        end
      end
    end

    private

    def pool
      raise ArgumentError, 'missing block' unless block_given?

      Gitlab::Redis::SharedState.with { |redis| yield redis }
    end

    def hkey(key)
      key.to_s.split(':') # this assumes the default strategy in gitlab-experiment
    end

    def read_entry(key, **options)
      value = pool { |redis| redis.hget(*hkey(key)) }
      value.nil? ? nil : ActiveSupport::Cache::Entry.new(value)
    end

    def write_entry(key, entry, **options)
      return false if entry.value.blank? # don't cache any empty values

      pool { |redis| redis.hset(*hkey(key), entry.value) }
    end

    def delete_entry(key, **options)
      pool { |redis| redis.hdel(*hkey(key)) }
    end
  end
end