File: base_interface.rb

package info (click to toggle)
ruby-gitlab-experiment 0.9.1-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 260 kB
  • sloc: ruby: 1,202; makefile: 7
file content (111 lines) | stat: -rw-r--r-- 3,365 bytes parent folder | download
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
# frozen_string_literal: true

module Gitlab
  class Experiment
    module BaseInterface
      extend ActiveSupport::Concern

      class_methods do
        def configure
          yield Configuration
        end

        def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
          name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
          name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
          suffix ? name : name.sub(/_#{suffix_word}$/, '')
        end

        def base?
          self == Gitlab::Experiment || name == Configuration.base_class
        end

        def constantize(name = nil)
          return self if name.nil?

          experiment_class = experiment_name(name).classify
          experiment_class.safe_constantize || begin
            return Configuration.base_class.constantize unless Configuration.strict_registration

            raise UnregisteredExperiment, <<~ERR
              No experiment registered for `#{name}`. Please register the experiment by defining a class:

              class #{experiment_class} < #{Configuration.base_class}
                control
                candidate { 'candidate' }
              end
            ERR
          end
        end

        def from_param(id)
          %r{/?(?<name>.*):(?<key>.*)$} =~ id
          name = CGI.unescape(name) if name
          constantize(name).new(name).tap { |e| e.context.key(key) }
        end
      end

      def initialize(name = nil, variant_name = nil, **context)
        raise ArgumentError, 'name is required' if name.blank? && self.class.base?

        @_name = self.class.experiment_name(name, suffix: false)
        @_context = Context.new(self, **context)
        @_assigned_variant_name = cache_variant(variant_name) { nil } if variant_name.present?

        yield self if block_given?
      end

      def inspect
        "#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} name=#{name} context=#{context.value}>"
      end

      def run(variant_name)
        behaviors.freeze
        context.freeze

        block = behaviors[variant_name]
        raise BehaviorMissingError, "the `#{variant_name}` variant hasn't been registered" if block.nil?

        result = block.call
        publish(result) if enabled?

        result
      end

      def id
        "#{name}:#{context.key}"
      end

      alias_method :to_param, :id

      def process_redirect_url(url)
        return unless Configuration.redirect_url_validator&.call(url)

        track('visited', url: url)
        url # return the url, which allows for mutation
      end

      def key_for(source, seed = name)
        return source if source.is_a?(String)

        source = source.keys + source.values if source.is_a?(Hash)

        ingredients = Array(source).map { |v| identify(v) }
        ingredients.unshift(seed).unshift(Configuration.context_key_secret)

        Digest::SHA2.new(Configuration.context_key_bit_length).hexdigest(ingredients.join('|')) # rubocop:disable Fips/OpenSSL
      end

      # @deprecated
      def variant_names
        Configuration.deprecated(
          :variant_names,
          'instead use `behavior.names`, which includes :control',
          version: '0.8.0'
        )

        behaviors.keys - [:control]
      end
    end
  end
end