gitlab-experiment 0.4.12 → 0.5.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 +37 -30
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +13 -10
- data/lib/gitlab/experiment.rb +31 -85
- data/lib/gitlab/experiment/base_interface.rb +85 -0
- data/lib/gitlab/experiment/cache.rb +2 -0
- data/lib/gitlab/experiment/callbacks.rb +2 -0
- data/lib/gitlab/experiment/configuration.rb +24 -5
- data/lib/gitlab/experiment/context.rb +2 -0
- data/lib/gitlab/experiment/dsl.rb +10 -1
- data/lib/gitlab/experiment/rollout.rb +27 -0
- data/lib/gitlab/experiment/rollout/first.rb +16 -0
- data/lib/gitlab/experiment/rollout/random.rb +15 -0
- data/lib/gitlab/experiment/rollout/round_robin.rb +23 -0
- data/lib/gitlab/experiment/rspec.rb +14 -7
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +7 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 31e4979de3006211bcd1f63882215ae9213c3a22de25e7cca8546f3fce2ef649
         | 
| 4 | 
            +
              data.tar.gz: cb865da8c87f019755194c98e073720f5f246b5a2c57e6d511c2a280d78ff996
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 7906faeb21deaa6e0be322c94c0fd45ea200b5419ac5aacd2d2656b416ac08c21654e84f0ef3d2db5bb33c819dc73020267488a6f7f1ac045b66591ec04ae334
         | 
| 7 | 
            +
              data.tar.gz: e0f9328d5c8f1eca74681e749e01cc6ccf513d619b3c4aa522c45d08a4692d370a96d97437975285fa26dcebad0a7e0a3a8356724b980902ba1b86c8fa82f0a1
         | 
    
        data/README.md
    CHANGED
    
    | @@ -3,7 +3,7 @@ GitLab Experiment | |
| 3 3 |  | 
| 4 4 | 
             
            <img alt="experiment" src="/uploads/60990b2dbf4c0406bbf8b7f998de2dea/experiment.png" align="right" width="40%">
         | 
| 5 5 |  | 
| 6 | 
            -
            Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it.
         | 
| 6 | 
            +
            Here at GitLab, we run experiments as A/B/n tests and review the data the experiment generates. From that data, we determine the best performing variant and promote it as the new default code path. Or revert back to the control if no variant outperformed it. You can read our [Experiment Guide](https://docs.gitlab.com/ee/development/experiment_guide/) docs if you're curious about how we use this gem internally.
         | 
| 7 7 |  | 
| 8 8 | 
             
            This library provides a clean and elegant DSL (domain specific language) to define, run, and track your GitLab experiment.
         | 
| 9 9 |  | 
| @@ -64,7 +64,7 @@ end | |
| 64 64 |  | 
| 65 65 | 
             
            You can define the experiment using simple control/candidate paths, or provide named variants.
         | 
| 66 66 |  | 
| 67 | 
            -
            Handling  | 
| 67 | 
            +
            Handling [multivariate](https://en.wikipedia.org/wiki/Multivariate_statistics) experiments is up to the configuration you provide around resolving variants. But in our example we may want to try with and without the confirmation. We can run any number of variations in our experiments this way.
         | 
| 68 68 |  | 
| 69 69 | 
             
            ```ruby
         | 
| 70 70 | 
             
            experiment(:notification_toggle, actor: user) do |e|
         | 
| @@ -149,37 +149,13 @@ experiment(:notification_toggle, actor: user) do |e| | |
| 149 149 | 
             
            end
         | 
| 150 150 | 
             
            ```
         | 
| 151 151 |  | 
| 152 | 
            -
            <details>
         | 
| 153 | 
            -
              <summary>You can also use the lower level class interface...</summary>
         | 
| 154 | 
            -
             | 
| 155 | 
            -
            ### Using the `.run` approach
         | 
| 156 | 
            -
             | 
| 157 | 
            -
            This is useful if you haven't included the DSL and so don't have access to the `experiment` method, but still want to execute an experiment. This is ultimately what the `experiment` method calls through to, and the method signatures are the same.
         | 
| 158 | 
            -
             | 
| 159 | 
            -
            ```ruby
         | 
| 160 | 
            -
            exp = Gitlab::Experiment.run(:notification_toggle, actor: user) do |e|
         | 
| 161 | 
            -
              # Context may be passed in the block, but must be finalized before calling
         | 
| 162 | 
            -
              # run or track.
         | 
| 163 | 
            -
              e.context(project: project) # add the project to the context
         | 
| 164 | 
            -
             | 
| 165 | 
            -
              # Define the control and candidate variant.
         | 
| 166 | 
            -
              e.use { render_toggle } # control
         | 
| 167 | 
            -
              e.try { render_button } # candidate
         | 
| 168 | 
            -
            end
         | 
| 169 | 
            -
             | 
| 170 | 
            -
            # Track an event on the experiment we've defined.
         | 
| 171 | 
            -
            exp.track(:clicked_button)
         | 
| 172 | 
            -
            ```
         | 
| 173 | 
            -
             | 
| 174 | 
            -
            </details>
         | 
| 175 | 
            -
             | 
| 176 152 | 
             
            <details>
         | 
| 177 153 | 
             
              <summary>You can also specify the variant to use for segmentation...</summary>
         | 
| 178 154 |  | 
| 179 | 
            -
            ### Specifying variant
         | 
| 180 | 
            -
             | 
| 181 155 | 
             
            Generally, defining segmentation rules is a better way to approach routing into specific variants, but it's possible to explicitly specify the variant when running an experiment. It's important to know what this might do to your data during rollout, so use this with careful consideration.
         | 
| 182 156 |  | 
| 157 | 
            +
            Any time a specific variant is provided (including `:control`) it will be cached for that context, if caching is enabled. 
         | 
| 158 | 
            +
             | 
| 183 159 | 
             
            ```ruby
         | 
| 184 160 | 
             
            experiment(:notification_toggle, :no_interface, actor: user) do |e|
         | 
| 185 161 | 
             
              e.use { render_toggle } # control
         | 
| @@ -360,6 +336,37 @@ experiment(:example, actor: user, project: project) | |
| 360 336 |  | 
| 361 337 | 
             
            For edge cases, you can pass the cookie through by assigning it yourself -- e.g. `actor: request.cookie_jar.signed['example_actor']`. The cookie name is the full experiment name (including any configured prefix) with `_actor` appended -- e.g. `gitlab_notification_toggle_actor` for the `:notification_toggle` experiment key with a configured prefix of `gitlab`.
         | 
| 362 338 |  | 
| 339 | 
            +
            ## How it works
         | 
| 340 | 
            +
             | 
| 341 | 
            +
            The way the gem works is best described using the following decision tree illustration. When an experiment is run, the following logic is executed to resolve what experience should be provided, given how the experiment is defined, and the context provided.
         | 
| 342 | 
            +
             
         | 
| 343 | 
            +
            ```mermaid
         | 
| 344 | 
            +
            graph TD
         | 
| 345 | 
            +
                GP[General Pool/Population] --> Enabled?
         | 
| 346 | 
            +
                Enabled? -->|Yes| Cached?[Cached? / Pre-segmented?]
         | 
| 347 | 
            +
                Enabled? -->|No| Excluded[Control / No Tracking]
         | 
| 348 | 
            +
                Cached? -->|No| Excluded?
         | 
| 349 | 
            +
                Cached? -->|Yes| Cached[Cached Value]
         | 
| 350 | 
            +
                Excluded? -->|Yes / Cached| Excluded
         | 
| 351 | 
            +
                Excluded? -->|No| Segmented?
         | 
| 352 | 
            +
                Segmented? -->|Yes / Cached| VariantA
         | 
| 353 | 
            +
                Segmented? -->|No| Included?[Experiment Group?]
         | 
| 354 | 
            +
                Included? -->|Yes| Rollout
         | 
| 355 | 
            +
                Included? -->|No| Control
         | 
| 356 | 
            +
                Rollout -->|Cached| VariantA
         | 
| 357 | 
            +
                Rollout -->|Cached| VariantB
         | 
| 358 | 
            +
                Rollout -->|Cached| VariantC
         | 
| 359 | 
            +
             | 
| 360 | 
            +
            classDef included fill:#380d75,color:#ffffff,stroke:none
         | 
| 361 | 
            +
            classDef excluded fill:#fca121,stroke:none
         | 
| 362 | 
            +
            classDef cached fill:#2e2e2e,color:#ffffff,stroke:none
         | 
| 363 | 
            +
            classDef default fill:#fff,stroke:#6e49cb
         | 
| 364 | 
            +
             | 
| 365 | 
            +
            class VariantA,VariantB,VariantC included
         | 
| 366 | 
            +
            class Control,Excluded excluded
         | 
| 367 | 
            +
            class Cached cached
         | 
| 368 | 
            +
            ```
         | 
| 369 | 
            +
             | 
| 363 370 | 
             
            ## Configuration
         | 
| 364 371 |  | 
| 365 372 | 
             
            This gem needs to be configured before being used in a meaningful way.
         | 
| @@ -534,7 +541,7 @@ https://gitlab.com/gitlab-org/gitlab-experiment. This project is intended to be | |
| 534 541 | 
             
            safe, welcoming space for collaboration, and contributors are expected to adhere
         | 
| 535 542 | 
             
            to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
         | 
| 536 543 |  | 
| 537 | 
            -
            ## Release  | 
| 544 | 
            +
            ## Release process
         | 
| 538 545 |  | 
| 539 546 | 
             
            Please refer to the [Release Process](docs/release_process.md).
         | 
| 540 547 |  | 
| @@ -543,7 +550,7 @@ Please refer to the [Release Process](docs/release_process.md). | |
| 543 550 | 
             
            The gem is available as open source under the terms of the
         | 
| 544 551 | 
             
            [MIT License](http://opensource.org/licenses/MIT).
         | 
| 545 552 |  | 
| 546 | 
            -
            ## Code of  | 
| 553 | 
            +
            ## Code of conduct
         | 
| 547 554 |  | 
| 548 555 | 
             
            Everyone interacting in the `Gitlab::Experiment` project’s codebases, issue trackers,
         | 
| 549 556 | 
             
            chat rooms and mailing lists is expected to follow the
         | 
| @@ -22,20 +22,23 @@ Gitlab::Experiment.configure do |config| | |
| 22 22 | 
             
              # `['www.gitlab.com', '.gitlab.com']`.
         | 
| 23 23 | 
             
              config.cookie_domain = :all
         | 
| 24 24 |  | 
| 25 | 
            -
              #  | 
| 25 | 
            +
              # The default rollout strategy that works for single and multi-variants.
         | 
| 26 26 | 
             
              #
         | 
| 27 | 
            -
              #  | 
| 28 | 
            -
              #  | 
| 27 | 
            +
              # You can provide your own rollout strategies and override them per
         | 
| 28 | 
            +
              # experiment.
         | 
| 29 | 
            +
              #
         | 
| 30 | 
            +
              # Examples include:
         | 
| 31 | 
            +
              #   Rollout::First, Rollout::Random, Rollout::RoundRobin
         | 
| 32 | 
            +
              config.default_rollout = Gitlab::Experiment::Rollout::First
         | 
| 33 | 
            +
             | 
| 34 | 
            +
              # Logic this project uses to determine inclusion in a given experiment.
         | 
| 35 | 
            +
              #
         | 
| 36 | 
            +
              # Expected to return a boolean value.
         | 
| 29 37 | 
             
              #
         | 
| 30 38 | 
             
              # This block is executed within the scope of the experiment and so can access
         | 
| 31 39 | 
             
              # experiment methods, like `name`, `context`, and `signature`.
         | 
| 32 | 
            -
              config. | 
| 33 | 
            -
                 | 
| 34 | 
            -
                requested_variant
         | 
| 35 | 
            -
             | 
| 36 | 
            -
                # Run the candidate, unless a variant was requested, with a fallback:
         | 
| 37 | 
            -
                #
         | 
| 38 | 
            -
                # requested_variant || variant_names.first || nil
         | 
| 40 | 
            +
              config.inclusion_resolver = lambda do |requested_variant|
         | 
| 41 | 
            +
                false
         | 
| 39 42 | 
             
              end
         | 
| 40 43 |  | 
| 41 44 | 
             
              # Tracking behavior can be implemented to link an event to an experiment.
         | 
    
        data/lib/gitlab/experiment.rb
    CHANGED
    
    | @@ -3,11 +3,15 @@ | |
| 3 3 | 
             
            require 'scientist'
         | 
| 4 4 | 
             
            require 'active_support/callbacks'
         | 
| 5 5 | 
             
            require 'active_support/cache'
         | 
| 6 | 
            +
            require 'active_support/concern'
         | 
| 6 7 | 
             
            require 'active_support/core_ext/object/blank'
         | 
| 7 8 | 
             
            require 'active_support/core_ext/string/inflections'
         | 
| 9 | 
            +
            require 'active_support/core_ext/module/delegation'
         | 
| 8 10 |  | 
| 11 | 
            +
            require 'gitlab/experiment/base_interface'
         | 
| 9 12 | 
             
            require 'gitlab/experiment/cache'
         | 
| 10 13 | 
             
            require 'gitlab/experiment/callbacks'
         | 
| 14 | 
            +
            require 'gitlab/experiment/rollout'
         | 
| 11 15 | 
             
            require 'gitlab/experiment/configuration'
         | 
| 12 16 | 
             
            require 'gitlab/experiment/cookies'
         | 
| 13 17 | 
             
            require 'gitlab/experiment/context'
         | 
| @@ -18,57 +22,29 @@ require 'gitlab/experiment/engine' if defined?(Rails::Engine) | |
| 18 22 |  | 
| 19 23 | 
             
            module Gitlab
         | 
| 20 24 | 
             
              class Experiment
         | 
| 21 | 
            -
                include  | 
| 25 | 
            +
                include BaseInterface
         | 
| 22 26 | 
             
                include Cache
         | 
| 23 27 | 
             
                include Callbacks
         | 
| 24 28 |  | 
| 25 29 | 
             
                class << self
         | 
| 26 | 
            -
                  def  | 
| 27 | 
            -
                     | 
| 28 | 
            -
                  end
         | 
| 29 | 
            -
             | 
| 30 | 
            -
                  def run(name = nil, variant_name = nil, **context, &block)
         | 
| 31 | 
            -
                    raise ArgumentError, 'name is required' if name.nil? && base?
         | 
| 32 | 
            -
             | 
| 33 | 
            -
                    instance = constantize(name).new(name, variant_name, **context, &block)
         | 
| 34 | 
            -
                    return instance unless block
         | 
| 35 | 
            -
             | 
| 36 | 
            -
                    instance.context.frozen? ? instance.run : instance.tap(&:run)
         | 
| 37 | 
            -
                  end
         | 
| 38 | 
            -
             | 
| 39 | 
            -
                  def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
         | 
| 40 | 
            -
                    name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
         | 
| 41 | 
            -
                    name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
         | 
| 42 | 
            -
                    suffix ? name : name.sub(/_#{suffix_word}$/, '')
         | 
| 43 | 
            -
                  end
         | 
| 30 | 
            +
                  def default_rollout(rollout = nil)
         | 
| 31 | 
            +
                    return @rollout ||= Configuration.default_rollout if rollout.blank?
         | 
| 44 32 |  | 
| 45 | 
            -
             | 
| 46 | 
            -
                    self == Gitlab::Experiment || name == Configuration.base_class
         | 
| 47 | 
            -
                  end
         | 
| 48 | 
            -
             | 
| 49 | 
            -
                  private
         | 
| 50 | 
            -
             | 
| 51 | 
            -
                  def constantize(name = nil)
         | 
| 52 | 
            -
                    return self if name.nil?
         | 
| 53 | 
            -
             | 
| 54 | 
            -
                    experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
         | 
| 33 | 
            +
                    @rollout = Rollout.resolve(rollout)
         | 
| 55 34 | 
             
                  end
         | 
| 56 35 | 
             
                end
         | 
| 57 36 |  | 
| 58 | 
            -
                def  | 
| 59 | 
            -
                   | 
| 60 | 
            -
             | 
| 61 | 
            -
                  @name = self.class.experiment_name(name, suffix: false)
         | 
| 62 | 
            -
                  @context = Context.new(self, **context)
         | 
| 63 | 
            -
                  @variant_name = cache_variant(variant_name) { nil } if variant_name.present?
         | 
| 64 | 
            -
             | 
| 65 | 
            -
                  compare { false }
         | 
| 37 | 
            +
                def name
         | 
| 38 | 
            +
                  [Configuration.name_prefix, @name].compact.join('_')
         | 
| 39 | 
            +
                end
         | 
| 66 40 |  | 
| 67 | 
            -
             | 
| 41 | 
            +
                def use(&block)
         | 
| 42 | 
            +
                  try(:control, &block)
         | 
| 68 43 | 
             
                end
         | 
| 69 44 |  | 
| 70 | 
            -
                def  | 
| 71 | 
            -
                   | 
| 45 | 
            +
                def try(name = nil, &block)
         | 
| 46 | 
            +
                  name = (name || :candidate).to_s
         | 
| 47 | 
            +
                  behaviors[name] = block
         | 
| 72 48 | 
             
                end
         | 
| 73 49 |  | 
| 74 50 | 
             
                def context(value = nil)
         | 
| @@ -99,6 +75,12 @@ module Gitlab | |
| 99 75 | 
             
                  @resolving_variant = false
         | 
| 100 76 | 
             
                end
         | 
| 101 77 |  | 
| 78 | 
            +
                def rollout(rollout = nil)
         | 
| 79 | 
            +
                  return @rollout ||= self.class.default_rollout if rollout.blank?
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                  @rollout = Rollout.resolve(rollout)
         | 
| 82 | 
            +
                end
         | 
| 83 | 
            +
             | 
| 102 84 | 
             
                def run(variant_name = nil)
         | 
| 103 85 | 
             
                  @result ||= super(variant(variant_name).name)
         | 
| 104 86 | 
             
                end
         | 
| @@ -113,41 +95,6 @@ module Gitlab | |
| 113 95 | 
             
                  instance_exec(action, event_args, &Configuration.tracking_behavior)
         | 
| 114 96 | 
             
                end
         | 
| 115 97 |  | 
| 116 | 
            -
                def name
         | 
| 117 | 
            -
                  [Configuration.name_prefix, @name].compact.join('_')
         | 
| 118 | 
            -
                end
         | 
| 119 | 
            -
             | 
| 120 | 
            -
                def variant_names
         | 
| 121 | 
            -
                  @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
         | 
| 122 | 
            -
                end
         | 
| 123 | 
            -
             | 
| 124 | 
            -
                def behaviors
         | 
| 125 | 
            -
                  @behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
         | 
| 126 | 
            -
                    next unless name.end_with?('_behavior')
         | 
| 127 | 
            -
             | 
| 128 | 
            -
                    behavior_name = name.to_s.sub(/_behavior$/, '')
         | 
| 129 | 
            -
                    behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
         | 
| 130 | 
            -
                  end
         | 
| 131 | 
            -
                end
         | 
| 132 | 
            -
             | 
| 133 | 
            -
                def try(name = nil, &block)
         | 
| 134 | 
            -
                  name = (name || 'candidate').to_s
         | 
| 135 | 
            -
                  behaviors[name] = block
         | 
| 136 | 
            -
                end
         | 
| 137 | 
            -
             | 
| 138 | 
            -
                def signature
         | 
| 139 | 
            -
                  { variant: variant.name, experiment: name }.merge(context.signature)
         | 
| 140 | 
            -
                end
         | 
| 141 | 
            -
             | 
| 142 | 
            -
                def id
         | 
| 143 | 
            -
                  "#{name}:#{key_for(context.value)}"
         | 
| 144 | 
            -
                end
         | 
| 145 | 
            -
                alias_method :session_id, :id
         | 
| 146 | 
            -
             | 
| 147 | 
            -
                def flipper_id
         | 
| 148 | 
            -
                  "Experiment;#{id}"
         | 
| 149 | 
            -
                end
         | 
| 150 | 
            -
             | 
| 151 98 | 
             
                def enabled?
         | 
| 152 99 | 
             
                  true
         | 
| 153 100 | 
             
                end
         | 
| @@ -159,10 +106,18 @@ module Gitlab | |
| 159 106 | 
             
                    !run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
         | 
| 160 107 | 
             
                end
         | 
| 161 108 |  | 
| 109 | 
            +
                def experiment_group?
         | 
| 110 | 
            +
                  instance_exec(@variant_name, &Configuration.inclusion_resolver)
         | 
| 111 | 
            +
                end
         | 
| 112 | 
            +
             | 
| 162 113 | 
             
                def should_track?
         | 
| 163 114 | 
             
                  enabled? && !excluded?
         | 
| 164 115 | 
             
                end
         | 
| 165 116 |  | 
| 117 | 
            +
                def signature
         | 
| 118 | 
            +
                  { variant: variant.name, experiment: name }.merge(context.signature)
         | 
| 119 | 
            +
                end
         | 
| 120 | 
            +
             | 
| 166 121 | 
             
                def key_for(hash)
         | 
| 167 122 | 
             
                  instance_exec(hash, &Configuration.context_hash_strategy)
         | 
| 168 123 | 
             
                end
         | 
| @@ -175,17 +130,8 @@ module Gitlab | |
| 175 130 | 
             
                  :unsegmented
         | 
| 176 131 | 
             
                end
         | 
| 177 132 |  | 
| 178 | 
            -
                def variant_assigned?
         | 
| 179 | 
            -
                  !@variant_name.nil?
         | 
| 180 | 
            -
                end
         | 
| 181 | 
            -
             | 
| 182 133 | 
             
                def resolve_variant_name
         | 
| 183 | 
            -
                   | 
| 184 | 
            -
                end
         | 
| 185 | 
            -
             | 
| 186 | 
            -
                def generate_result(variant_name)
         | 
| 187 | 
            -
                  observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
         | 
| 188 | 
            -
                  Scientist::Result.new(self, [observation], observation)
         | 
| 134 | 
            +
                  rollout.new(self).execute if experiment_group?
         | 
| 189 135 | 
             
                end
         | 
| 190 136 | 
             
              end
         | 
| 191 137 | 
             
            end
         | 
| @@ -0,0 +1,85 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Gitlab
         | 
| 4 | 
            +
              class Experiment
         | 
| 5 | 
            +
                module BaseInterface
         | 
| 6 | 
            +
                  extend ActiveSupport::Concern
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  # don't `include` here so we don't override the default scientist class
         | 
| 9 | 
            +
                  Scientist::Experiment.send(:append_features, self) # rubocop:disable GitlabSecurity/PublicSend
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                  class_methods do
         | 
| 12 | 
            +
                    include Scientist::Experiment::RaiseOnMismatch
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                    def configure
         | 
| 15 | 
            +
                      yield Configuration
         | 
| 16 | 
            +
                    end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                    def experiment_name(name = nil, suffix: true, suffix_word: 'experiment')
         | 
| 19 | 
            +
                      name = (name.presence || self.name).to_s.underscore.sub(%r{(?<char>[_/]|)#{suffix_word}$}, '')
         | 
| 20 | 
            +
                      name = "#{name}#{Regexp.last_match(:char) || '_'}#{suffix_word}"
         | 
| 21 | 
            +
                      suffix ? name : name.sub(/_#{suffix_word}$/, '')
         | 
| 22 | 
            +
                    end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                    def base?
         | 
| 25 | 
            +
                      self == Gitlab::Experiment || name == Configuration.base_class
         | 
| 26 | 
            +
                    end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                    def constantize(name = nil)
         | 
| 29 | 
            +
                      return self if name.nil?
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                      experiment_name(name).classify.safe_constantize || Configuration.base_class.constantize
         | 
| 32 | 
            +
                    end
         | 
| 33 | 
            +
                  end
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                  def initialize(name = nil, variant_name = nil, **context)
         | 
| 36 | 
            +
                    raise ArgumentError, 'name is required' if name.blank? && self.class.base?
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                    @name = self.class.experiment_name(name, suffix: false)
         | 
| 39 | 
            +
                    @context = Context.new(self, **context)
         | 
| 40 | 
            +
                    @variant_name = cache_variant(variant_name) { nil } if variant_name.present?
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                    compare { false }
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                    yield self if block_given?
         | 
| 45 | 
            +
                  end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                  def inspect
         | 
| 48 | 
            +
                    "#<#{self.class.name || 'AnonymousClass'}:#{format('0x%016X', __id__)} @name=#{name} @context=#{context.value}>"
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  def id
         | 
| 52 | 
            +
                    "#{name}:#{key_for(context.value)}"
         | 
| 53 | 
            +
                  end
         | 
| 54 | 
            +
                  alias_method :session_id, :id
         | 
| 55 | 
            +
             | 
| 56 | 
            +
                  def flipper_id
         | 
| 57 | 
            +
                    "Experiment;#{id}"
         | 
| 58 | 
            +
                  end
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                  def variant_names
         | 
| 61 | 
            +
                    @variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
         | 
| 62 | 
            +
                  end
         | 
| 63 | 
            +
             | 
| 64 | 
            +
                  def behaviors
         | 
| 65 | 
            +
                    @behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
         | 
| 66 | 
            +
                      next unless name.end_with?('_behavior')
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                      behavior_name = name.to_s.sub(/_behavior$/, '')
         | 
| 69 | 
            +
                      behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
         | 
| 70 | 
            +
                    end
         | 
| 71 | 
            +
                  end
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                  protected
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                  def variant_assigned?
         | 
| 76 | 
            +
                    !@variant_name.nil?
         | 
| 77 | 
            +
                  end
         | 
| 78 | 
            +
             | 
| 79 | 
            +
                  def generate_result(variant_name)
         | 
| 80 | 
            +
                    observation = Scientist::Observation.new(variant_name, self, &behaviors[variant_name])
         | 
| 81 | 
            +
                    Scientist::Result.new(self, [observation], observation)
         | 
| 82 | 
            +
                  end
         | 
| 83 | 
            +
                end
         | 
| 84 | 
            +
              end
         | 
| 85 | 
            +
            end
         | 
| @@ -4,6 +4,8 @@ require 'singleton' | |
| 4 4 | 
             
            require 'logger'
         | 
| 5 5 | 
             
            require 'digest'
         | 
| 6 6 |  | 
| 7 | 
            +
            require 'active_support/deprecation'
         | 
| 8 | 
            +
             | 
| 7 9 | 
             
            module Gitlab
         | 
| 8 10 | 
             
              class Experiment
         | 
| 9 11 | 
             
                class Configuration
         | 
| @@ -24,10 +26,13 @@ module Gitlab | |
| 24 26 | 
             
                  # The domain to use on cookies.
         | 
| 25 27 | 
             
                  @cookie_domain = :all
         | 
| 26 28 |  | 
| 27 | 
            -
                  #  | 
| 28 | 
            -
                   | 
| 29 | 
            -
             | 
| 30 | 
            -
             | 
| 29 | 
            +
                  # The default rollout strategy that works for single and multi-variants.
         | 
| 30 | 
            +
                  @default_rollout = Rollout::First
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                  # Logic this project uses to determine inclusion in a given experiment.
         | 
| 33 | 
            +
                  # Expected to return a boolean value.
         | 
| 34 | 
            +
                  @inclusion_resolver = lambda do |requested_variant|
         | 
| 35 | 
            +
                    false
         | 
| 31 36 | 
             
                  end
         | 
| 32 37 |  | 
| 33 38 | 
             
                  # Tracking behavior can be implemented to link an event to an experiment.
         | 
| @@ -47,13 +52,27 @@ module Gitlab | |
| 47 52 | 
             
                  end
         | 
| 48 53 |  | 
| 49 54 | 
             
                  class << self
         | 
| 55 | 
            +
                    # TODO: Added deprecation in release 0.5.0
         | 
| 56 | 
            +
                    def variant_resolver
         | 
| 57 | 
            +
                      ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
         | 
| 58 | 
            +
                        'block that returns a boolean.')
         | 
| 59 | 
            +
                      @inclusion_resolver
         | 
| 60 | 
            +
                    end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                    def variant_resolver=(block)
         | 
| 63 | 
            +
                      ActiveSupport::Deprecation.warn('variant_resolver is deprecated, instead use `inclusion_resolver` with a' \
         | 
| 64 | 
            +
                        'block that returns a boolean.')
         | 
| 65 | 
            +
                      @inclusion_resolver = block
         | 
| 66 | 
            +
                    end
         | 
| 67 | 
            +
             | 
| 50 68 | 
             
                    attr_accessor(
         | 
| 51 69 | 
             
                      :name_prefix,
         | 
| 52 70 | 
             
                      :logger,
         | 
| 53 71 | 
             
                      :base_class,
         | 
| 54 72 | 
             
                      :cache,
         | 
| 55 73 | 
             
                      :cookie_domain,
         | 
| 56 | 
            -
                      : | 
| 74 | 
            +
                      :default_rollout,
         | 
| 75 | 
            +
                      :inclusion_resolver,
         | 
| 57 76 | 
             
                      :tracking_behavior,
         | 
| 58 77 | 
             
                      :publishing_behavior,
         | 
| 59 78 | 
             
                      :context_hash_strategy
         | 
| @@ -4,8 +4,17 @@ module Gitlab | |
| 4 4 | 
             
              class Experiment
         | 
| 5 5 | 
             
                module Dsl
         | 
| 6 6 | 
             
                  def experiment(name, variant_name = nil, **context, &block)
         | 
| 7 | 
            +
                    raise ArgumentError, 'name is required' if name.nil?
         | 
| 8 | 
            +
             | 
| 7 9 | 
             
                    context[:request] ||= request if respond_to?(:request)
         | 
| 8 | 
            -
             | 
| 10 | 
            +
             | 
| 11 | 
            +
                    base = Configuration.base_class.constantize
         | 
| 12 | 
            +
                    klass = base.constantize(name) || base
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                    instance = klass.new(name, variant_name, **context, &block)
         | 
| 15 | 
            +
                    return instance unless block
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                    instance.context.frozen? ? instance.run : instance.tap(&:run)
         | 
| 9 18 | 
             
                  end
         | 
| 10 19 | 
             
                end
         | 
| 11 20 | 
             
              end
         | 
| @@ -0,0 +1,27 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Gitlab
         | 
| 4 | 
            +
              class Experiment
         | 
| 5 | 
            +
                module Rollout
         | 
| 6 | 
            +
                  autoload :First, 'gitlab/experiment/rollout/first.rb' # default strategy
         | 
| 7 | 
            +
                  autoload :Random, 'gitlab/experiment/rollout/random.rb'
         | 
| 8 | 
            +
                  autoload :RoundRobin, 'gitlab/experiment/rollout/round_robin.rb'
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  def self.resolve(klass)
         | 
| 11 | 
            +
                    return "#{name}::#{klass.to_s.classify}".constantize if klass.is_a?(Symbol) || klass.is_a?(String)
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                    klass
         | 
| 14 | 
            +
                  end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  class Base
         | 
| 17 | 
            +
                    attr_reader :experiment
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                    delegate :variant_names, :cache, to: :experiment
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                    def initialize(experiment)
         | 
| 22 | 
            +
                      @experiment = experiment
         | 
| 23 | 
            +
                    end
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
                end
         | 
| 26 | 
            +
              end
         | 
| 27 | 
            +
            end
         | 
| @@ -0,0 +1,16 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Gitlab
         | 
| 4 | 
            +
              class Experiment
         | 
| 5 | 
            +
                module Rollout
         | 
| 6 | 
            +
                  class First < Base
         | 
| 7 | 
            +
                    # This rollout strategy just picks the first variant name. It's the
         | 
| 8 | 
            +
                    # default resolver as it assumes a single variant. You should consider
         | 
| 9 | 
            +
                    # using a more advanced rollout if you have multiple variants.
         | 
| 10 | 
            +
                    def execute
         | 
| 11 | 
            +
                      variant_names.first
         | 
| 12 | 
            +
                    end
         | 
| 13 | 
            +
                  end
         | 
| 14 | 
            +
                end
         | 
| 15 | 
            +
              end
         | 
| 16 | 
            +
            end
         | 
| @@ -0,0 +1,15 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Gitlab
         | 
| 4 | 
            +
              class Experiment
         | 
| 5 | 
            +
                module Rollout
         | 
| 6 | 
            +
                  class Random < Base
         | 
| 7 | 
            +
                    # Pick a random variant if we're in the experiment group. It doesn't
         | 
| 8 | 
            +
                    # take into account small sample sizes but is useful and performant.
         | 
| 9 | 
            +
                    def execute
         | 
| 10 | 
            +
                      variant_names.sample
         | 
| 11 | 
            +
                    end
         | 
| 12 | 
            +
                  end
         | 
| 13 | 
            +
                end
         | 
| 14 | 
            +
              end
         | 
| 15 | 
            +
            end
         | 
| @@ -0,0 +1,23 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Gitlab
         | 
| 4 | 
            +
              class Experiment
         | 
| 5 | 
            +
                module Rollout
         | 
| 6 | 
            +
                  class RoundRobin < Base
         | 
| 7 | 
            +
                    KEY_NAME = :last_round_robin_variant
         | 
| 8 | 
            +
             | 
| 9 | 
            +
                    # Requires a cache to be configured.
         | 
| 10 | 
            +
                    #
         | 
| 11 | 
            +
                    # Keeps track of the number of assignments into the experiment group,
         | 
| 12 | 
            +
                    # and uses this to rotate "round robin" style through the variants
         | 
| 13 | 
            +
                    # that are defined.
         | 
| 14 | 
            +
                    #
         | 
| 15 | 
            +
                    # Relatively performant, but requires a cache, and is dependent on the
         | 
| 16 | 
            +
                    # performance of that cache store.
         | 
| 17 | 
            +
                    def execute
         | 
| 18 | 
            +
                      variant_names[(cache.attr_inc(KEY_NAME) - 1) % variant_names.size]
         | 
| 19 | 
            +
                    end
         | 
| 20 | 
            +
                  end
         | 
| 21 | 
            +
                end
         | 
| 22 | 
            +
              end
         | 
| 23 | 
            +
            end
         | 
| @@ -6,14 +6,19 @@ module Gitlab | |
| 6 6 | 
             
                  def stub_experiments(experiments)
         | 
| 7 7 | 
             
                    experiments.each do |name, variant|
         | 
| 8 8 | 
             
                      variant = :control if variant == false
         | 
| 9 | 
            -
                      raise ArgumentError, 'variant must be a symbol or false' unless variant.is_a?(Symbol)
         | 
| 10 9 |  | 
| 11 | 
            -
                       | 
| 10 | 
            +
                      base = Configuration.base_class.constantize
         | 
| 11 | 
            +
                      klass = base.constantize(name) || base
         | 
| 12 12 |  | 
| 13 13 | 
             
                      # We have to use this high level any_instance behavior as there's
         | 
| 14 14 | 
             
                      # not an alternative that allows multiple wrappings of `new`.
         | 
| 15 15 | 
             
                      allow_any_instance_of(klass).to receive(:enabled?).and_return(true)
         | 
| 16 | 
            -
             | 
| 16 | 
            +
             | 
| 17 | 
            +
                      if variant == true # passing true allows the rollout to do its job
         | 
| 18 | 
            +
                        allow_any_instance_of(klass).to receive(:experiment_group?).and_return(true)
         | 
| 19 | 
            +
                      else
         | 
| 20 | 
            +
                        allow_any_instance_of(klass).to receive(:resolve_variant_name).and_return(variant.to_s)
         | 
| 21 | 
            +
                      end
         | 
| 17 22 | 
             
                    end
         | 
| 18 23 | 
             
                  end
         | 
| 19 24 |  | 
| @@ -147,14 +152,16 @@ module Gitlab | |
| 147 152 | 
             
                      end
         | 
| 148 153 | 
             
                    end
         | 
| 149 154 |  | 
| 150 | 
            -
                    def receive_tracking_call_for( | 
| 151 | 
            -
                      receive(:track).with(*[ | 
| 155 | 
            +
                    def receive_tracking_call_for(expected_event, *expected_event_args)
         | 
| 156 | 
            +
                      receive(:track).with(*[expected_event, *expected_event_args]).and_wrap_original do |track, event, *event_args|
         | 
| 157 | 
            +
                        track.call(event, *event_args) # call the original
         | 
| 158 | 
            +
             | 
| 152 159 | 
             
                        if @expected_variant
         | 
| 153 | 
            -
                          expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant,  | 
| 160 | 
            +
                          expect(@experiment.variant.name).to eq(@expected_variant), failure_message(:variant, expected_event)
         | 
| 154 161 | 
             
                        end
         | 
| 155 162 |  | 
| 156 163 | 
             
                        if @expected_context
         | 
| 157 | 
            -
                          expect(@experiment.context.value).to include(@expected_context), failure_message(:context,  | 
| 164 | 
            +
                          expect(@experiment.context.value).to include(@expected_context), failure_message(:context, expected_event)
         | 
| 158 165 | 
             
                        end
         | 
| 159 166 | 
             
                      end
         | 
| 160 167 | 
             
                    end
         | 
    
        metadata
    CHANGED
    
    | @@ -1,14 +1,14 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: gitlab-experiment
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version: 0. | 
| 4 | 
            +
              version: 0.5.0
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - GitLab
         | 
| 8 8 | 
             
            autorequire: 
         | 
| 9 9 | 
             
            bindir: bin
         | 
| 10 10 | 
             
            cert_chain: []
         | 
| 11 | 
            -
            date: 2021- | 
| 11 | 
            +
            date: 2021-03-11 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: activesupport
         | 
| @@ -65,6 +65,7 @@ files: | |
| 65 65 | 
             
            - lib/generators/test_unit/experiment/experiment_generator.rb
         | 
| 66 66 | 
             
            - lib/generators/test_unit/experiment/templates/experiment_test.rb.tt
         | 
| 67 67 | 
             
            - lib/gitlab/experiment.rb
         | 
| 68 | 
            +
            - lib/gitlab/experiment/base_interface.rb
         | 
| 68 69 | 
             
            - lib/gitlab/experiment/cache.rb
         | 
| 69 70 | 
             
            - lib/gitlab/experiment/cache/redis_hash_store.rb
         | 
| 70 71 | 
             
            - lib/gitlab/experiment/callbacks.rb
         | 
| @@ -73,6 +74,10 @@ files: | |
| 73 74 | 
             
            - lib/gitlab/experiment/cookies.rb
         | 
| 74 75 | 
             
            - lib/gitlab/experiment/dsl.rb
         | 
| 75 76 | 
             
            - lib/gitlab/experiment/engine.rb
         | 
| 77 | 
            +
            - lib/gitlab/experiment/rollout.rb
         | 
| 78 | 
            +
            - lib/gitlab/experiment/rollout/first.rb
         | 
| 79 | 
            +
            - lib/gitlab/experiment/rollout/random.rb
         | 
| 80 | 
            +
            - lib/gitlab/experiment/rollout/round_robin.rb
         | 
| 76 81 | 
             
            - lib/gitlab/experiment/rspec.rb
         | 
| 77 82 | 
             
            - lib/gitlab/experiment/variant.rb
         | 
| 78 83 | 
             
            - lib/gitlab/experiment/version.rb
         |