gitlab-labkit 0.40.0 → 0.41.2
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/.copier-answers.yml +1 -1
- data/.gitlab-ci-asdf-versions.yml +2 -2
- data/.pre-commit-config.yaml +2 -2
- data/.rubocop_todo.yml +1 -1
- data/.tool-versions +2 -2
- data/README.md +1 -1
- data/lib/labkit/covered_experience/README.md +52 -1
- data/lib/labkit/covered_experience/current.rb +34 -0
- data/lib/labkit/covered_experience/error.rb +1 -1
- data/lib/labkit/covered_experience/experience.rb +118 -34
- data/lib/labkit/covered_experience/null.rb +9 -2
- data/lib/labkit/covered_experience.rb +37 -10
- data/lib/labkit/logging/json_logger.rb +1 -1
- data/lib/labkit/middleware/rack.rb +23 -0
- data/lib/labkit/middleware/sidekiq/client.rb +1 -0
- data/lib/labkit/middleware/sidekiq/covered_experience/client.rb +27 -0
- data/lib/labkit/middleware/sidekiq/covered_experience/server.rb +25 -0
- data/lib/labkit/middleware/sidekiq/covered_experience.rb +14 -0
- data/lib/labkit/middleware/sidekiq/server.rb +1 -0
- data/lib/labkit/middleware/sidekiq.rb +1 -0
- data/lib/labkit/rspec/README.md +11 -0
- data/lib/labkit/rspec/matchers/covered_experience_matchers.rb +10 -4
- metadata +5 -1
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 021f710ca320399402124ec377576c549cb5e323f59da70babd4e70f576cf12f
         | 
| 4 | 
            +
              data.tar.gz: ef32fd2ac8e127847a7124f73155b8fc0d7397d2d07a563dde469d0f3c08b58d
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: d6fa54d12f6fc16838d116316ca612ec5bc243e282b5b24ffca56547f9a4192e2ae59db05499cf18d4bb2d2f97c4295bb424dffadaa47f760c3614f8f6099e17
         | 
| 7 | 
            +
              data.tar.gz: 6ed7ef8bc8a7fa9cfe212ef1329c4dd86ec5613db5e6b80b42cc8e432ba49f10b6af200fdd10a46fd0e63198be4fd061a3555627002de158950adf1a2060ae47
         | 
    
        data/.copier-answers.yml
    CHANGED
    
    | @@ -3,7 +3,7 @@ | |
| 3 3 | 
             
            # See the project for instructions on how to update the project
         | 
| 4 4 | 
             
            #
         | 
| 5 5 | 
             
            # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
         | 
| 6 | 
            -
            _commit: v1. | 
| 6 | 
            +
            _commit: v1.36.0
         | 
| 7 7 | 
             
            _src_path: https://gitlab.com/gitlab-com/gl-infra/common-template-copier.git
         | 
| 8 8 | 
             
            ee_licensed: false
         | 
| 9 9 | 
             
            golang: false
         | 
| @@ -1,5 +1,5 @@ | |
| 1 1 | 
             
            # DO NOT MANUALLY EDIT; Run ./scripts/update-asdf-version-variables.sh to update this
         | 
| 2 2 | 
             
            variables:
         | 
| 3 | 
            -
                GL_ASDF_RUBY_VERSION: "3.4. | 
| 4 | 
            -
                GL_ASDF_SHELLCHECK_VERSION: "0. | 
| 3 | 
            +
                GL_ASDF_RUBY_VERSION: "3.4.6"
         | 
| 4 | 
            +
                GL_ASDF_SHELLCHECK_VERSION: "0.11"
         | 
| 5 5 | 
             
                GL_ASDF_SHFMT_VERSION: "3.12"
         | 
    
        data/.pre-commit-config.yaml
    CHANGED
    
    | @@ -5,7 +5,7 @@ | |
| 5 5 | 
             
            # exclude: '^fixtures/'
         | 
| 6 6 | 
             
            repos:
         | 
| 7 7 | 
             
              - repo: https://github.com/pre-commit/pre-commit-hooks
         | 
| 8 | 
            -
                rev:  | 
| 8 | 
            +
                rev: v6.0.0  # renovate:managed
         | 
| 9 9 | 
             
                hooks:
         | 
| 10 10 | 
             
                  - id: trailing-whitespace
         | 
| 11 11 | 
             
                  - id: end-of-file-fixer
         | 
| @@ -25,7 +25,7 @@ repos: | |
| 25 25 | 
             
              # Documentation available at
         | 
| 26 26 | 
             
              # https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks/-/blob/main/docs/pre-commit.md
         | 
| 27 27 | 
             
              - repo: https://gitlab.com/gitlab-com/gl-infra/common-ci-tasks
         | 
| 28 | 
            -
                rev: v2. | 
| 28 | 
            +
                rev: v2.92  # renovate:managed
         | 
| 29 29 |  | 
| 30 30 | 
             
                hooks:
         | 
| 31 31 | 
             
                  - id: shellcheck  # Run shellcheck for changed Shell files
         | 
    
        data/.rubocop_todo.yml
    CHANGED
    
    
    
        data/.tool-versions
    CHANGED
    
    | @@ -1,3 +1,3 @@ | |
| 1 | 
            -
            ruby 3.4. | 
| 1 | 
            +
            ruby 3.4.6
         | 
| 2 2 | 
             
            shfmt 3.12
         | 
| 3 | 
            -
            shellcheck 0. | 
| 3 | 
            +
            shellcheck 0.11
         | 
    
        data/README.md
    CHANGED
    
    | @@ -20,7 +20,7 @@ LabKit-Ruby provides functionality in a number of areas: | |
| 20 20 |  | 
| 21 21 | 
             
            1. `Labkit::Context` used for providing context information to log messages.
         | 
| 22 22 | 
             
            1. `Labkit::Correlation` for accessing the correlation id. (Generated and propagated by `Labkit::Context`)
         | 
| 23 | 
            -
            1. `Labkit::CoveredExperience` for tracking covered experiences. More on the [README](./lib/labkit/ | 
| 23 | 
            +
            1. `Labkit::CoveredExperience` for tracking covered experiences. More on the [README](./lib/labkit/covered_experience/README.md).
         | 
| 24 24 | 
             
            1. `Labkit::FIPS` for checking for FIPS mode and using FIPS-compliant algorithms.
         | 
| 25 25 | 
             
            1. `Labkit::Logging` for sanitizing log messages.
         | 
| 26 26 | 
             
            1. `Labkit::Metrics` for metrics. More on the [README](./lib/labkit/metrics/README.md).
         | 
| @@ -16,6 +16,23 @@ end | |
| 16 16 |  | 
| 17 17 | 
             
            This configuration affects all Covered Experience instances and their logging output.
         | 
| 18 18 |  | 
| 19 | 
            +
            ### Registry Path Configuration
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            By default, covered experience definitions are loaded from the `config/covered_experiences` directory. You can configure a custom registry path:
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            ```ruby
         | 
| 24 | 
            +
            Labkit::CoveredExperience.configure do |config|
         | 
| 25 | 
            +
              config.registry_path = "my/custom/path"
         | 
| 26 | 
            +
            end
         | 
| 27 | 
            +
            ```
         | 
| 28 | 
            +
             | 
| 29 | 
            +
            This allows you to:
         | 
| 30 | 
            +
            - Store covered experience definitions in a different directory structure
         | 
| 31 | 
            +
            - Use different paths for different environments
         | 
| 32 | 
            +
            - Organize definitions according to your application's needs
         | 
| 33 | 
            +
             | 
| 34 | 
            +
            **Note:** The registry is automatically reset when the configuration changes, so the new path takes effect immediately.
         | 
| 35 | 
            +
             | 
| 19 36 | 
             
            ### Covered Experience Definitions
         | 
| 20 37 |  | 
| 21 38 | 
             
            Covered experience definitions will be lazy loaded from the default directory (`config/covered_experiences`).
         | 
| @@ -100,6 +117,40 @@ experience.checkpoint | |
| 100 117 | 
             
            experience.complete
         | 
| 101 118 | 
             
            ```
         | 
| 102 119 |  | 
| 120 | 
            +
            #### Resuming Experiences
         | 
| 121 | 
            +
             | 
| 122 | 
            +
            You can resume a covered experience that was previously started and stored in the context. This is useful for distributed operations or when work spans multiple processes.
         | 
| 123 | 
            +
             | 
| 124 | 
            +
            Just like the start method, we can use a block to automatically complete a covered experience:
         | 
| 125 | 
            +
             | 
| 126 | 
            +
            ```ruby
         | 
| 127 | 
            +
            # Resume an experience from context (with block)
         | 
| 128 | 
            +
            Labkit::CoveredExperience.resume(:merge_request_creation) do |experience|
         | 
| 129 | 
            +
              # Continue the work from where it left off
         | 
| 130 | 
            +
              finalize_merge_request
         | 
| 131 | 
            +
             | 
| 132 | 
            +
              # Add checkpoints as needed
         | 
| 133 | 
            +
              experience.checkpoint
         | 
| 134 | 
            +
             | 
| 135 | 
            +
              send_notifications
         | 
| 136 | 
            +
            end
         | 
| 137 | 
            +
            ```
         | 
| 138 | 
            +
            Or manually:
         | 
| 139 | 
            +
             | 
| 140 | 
            +
            ```ruby
         | 
| 141 | 
            +
            # Resume an experience from context (manual control)
         | 
| 142 | 
            +
            experience = Labkit::CoveredExperience.resume(:merge_request_creation)
         | 
| 143 | 
            +
             | 
| 144 | 
            +
            # Continue the work
         | 
| 145 | 
            +
            finalize_merge_request
         | 
| 146 | 
            +
            experience.checkpoint
         | 
| 147 | 
            +
             | 
| 148 | 
            +
            # Complete the experience
         | 
| 149 | 
            +
            experience.complete
         | 
| 150 | 
            +
            ```
         | 
| 151 | 
            +
             | 
| 152 | 
            +
            **Note:** The `resume` method loads the start time from the Labkit context. If no covered experience data exists in the context, it behaves the same as calling methods on an unstarted experience and safely ignores the operation.
         | 
| 153 | 
            +
             | 
| 103 154 | 
             
            ### Error Handling
         | 
| 104 155 |  | 
| 105 156 | 
             
            When using the block form, errors are automatically captured:
         | 
| @@ -131,4 +182,4 @@ end | |
| 131 182 |  | 
| 132 183 | 
             
            - In `development` and `test` environments, accessing a non-existent covered experience will raise a `NotFoundError`
         | 
| 133 184 | 
             
            - In other environments, a null object is returned that safely ignores all method calls
         | 
| 134 | 
            -
            - Attempting to checkpoint or complete an unstarted experience will  | 
| 185 | 
            +
            - Attempting to checkpoint or complete an unstarted experience will safely ignore the operation
         | 
| @@ -0,0 +1,34 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require "active_support"
         | 
| 4 | 
            +
            require "labkit/covered_experience/experience"
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            module Labkit
         | 
| 7 | 
            +
              module CoveredExperience
         | 
| 8 | 
            +
                # The `Current` class represents a container for the current set
         | 
| 9 | 
            +
                # of `Labkit::CoveredExperience::Experience` instances started and
         | 
| 10 | 
            +
                # not yet completed.
         | 
| 11 | 
            +
                #
         | 
| 12 | 
            +
                # It uses `ActiveSupport::CurrentAttributes` to provide a thread-safe way to
         | 
| 13 | 
            +
                # store and access experiences throughout the request and background job lifecycle.
         | 
| 14 | 
            +
                #
         | 
| 15 | 
            +
                # Example usage:
         | 
| 16 | 
            +
                #   Labkit::CoveredExperience::Current.active_experiences << my_experience
         | 
| 17 | 
            +
                #   Labkit::CoveredExperience::Current.rehydrate("create_merge_request", "start_time" => "2025-08-22T10:02:15.237Z")
         | 
| 18 | 
            +
                class Current < ActiveSupport::CurrentAttributes
         | 
| 19 | 
            +
                  AGGREGATION_KEY = 'labkit_covered_experiences'
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  attribute :_active_experiences
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  def active_experiences
         | 
| 24 | 
            +
                    self._active_experiences ||= {}
         | 
| 25 | 
            +
                  end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                  def rehydrate(experience_id, **data)
         | 
| 28 | 
            +
                    instance = Labkit::CoveredExperience.get(experience_id).rehydrate(data)
         | 
| 29 | 
            +
                    active_experiences[instance.id] = instance
         | 
| 30 | 
            +
                    instance
         | 
| 31 | 
            +
                  end
         | 
| 32 | 
            +
                end
         | 
| 33 | 
            +
              end
         | 
| 34 | 
            +
            end
         | 
| @@ -3,7 +3,7 @@ | |
| 3 3 | 
             
            module Labkit
         | 
| 4 4 | 
             
              module CoveredExperience
         | 
| 5 5 | 
             
                CoveredExperienceError = Class.new(StandardError)
         | 
| 6 | 
            -
                UnstartedError = Class.new(CoveredExperienceError)
         | 
| 7 6 | 
             
                NotFoundError = Class.new(CoveredExperienceError)
         | 
| 7 | 
            +
                ReservedKeywordError = Class.new(CoveredExperienceError)
         | 
| 8 8 | 
             
              end
         | 
| 9 9 | 
             
            end
         | 
| @@ -1,6 +1,9 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            +
            require 'active_support/hash_with_indifferent_access'
         | 
| 4 | 
            +
            require 'forwardable'
         | 
| 3 5 | 
             
            require 'labkit/context'
         | 
| 6 | 
            +
            require 'labkit/covered_experience/current'
         | 
| 4 7 | 
             
            require 'labkit/covered_experience/error'
         | 
| 5 8 |  | 
| 6 9 | 
             
            module Labkit
         | 
| @@ -12,15 +15,48 @@ module Labkit | |
| 12 15 | 
             
                  async_slow: 300
         | 
| 13 16 | 
             
                }.freeze
         | 
| 14 17 |  | 
| 18 | 
            +
                RESERVED_KEYWORDS = %w[
         | 
| 19 | 
            +
                  checkpoint
         | 
| 20 | 
            +
                  covered_experience
         | 
| 21 | 
            +
                  feature_category
         | 
| 22 | 
            +
                  urgency
         | 
| 23 | 
            +
                  start_time
         | 
| 24 | 
            +
                  checkpoint_time
         | 
| 25 | 
            +
                  end_time
         | 
| 26 | 
            +
                  elapsed_time_s
         | 
| 27 | 
            +
                  urgency_threshold_s
         | 
| 28 | 
            +
                  error
         | 
| 29 | 
            +
                  error_message
         | 
| 30 | 
            +
                  success
         | 
| 31 | 
            +
                ].freeze
         | 
| 32 | 
            +
             | 
| 15 33 | 
             
                # The `Experience` class represents a single Covered Experience
         | 
| 16 34 | 
             
                # event to be measured and reported.
         | 
| 17 35 | 
             
                class Experience
         | 
| 18 | 
            -
                   | 
| 36 | 
            +
                  extend Forwardable
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                  attr_reader :error, :start_time
         | 
| 19 39 |  | 
| 20 40 | 
             
                  def initialize(definition)
         | 
| 21 41 | 
             
                    @definition = definition
         | 
| 22 42 | 
             
                  end
         | 
| 23 43 |  | 
| 44 | 
            +
                  def id
         | 
| 45 | 
            +
                    @definition.covered_experience
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  # Rehydrate an Experience instance from serialized data.
         | 
| 49 | 
            +
                  #
         | 
| 50 | 
            +
                  # @param data [Hash] A hash of serialized data.
         | 
| 51 | 
            +
                  # @return [Experience]
         | 
| 52 | 
            +
                  def rehydrate(data = {})
         | 
| 53 | 
            +
                    @start_time = Time.iso8601(data["start_time"]) if data&.has_key?("start_time") && data["start_time"]
         | 
| 54 | 
            +
                    self
         | 
| 55 | 
            +
                  rescue ArgumentError
         | 
| 56 | 
            +
                    warn("Invalid #{id}, start_time: #{data['start_time']}")
         | 
| 57 | 
            +
                    self
         | 
| 58 | 
            +
                  end
         | 
| 59 | 
            +
             | 
| 24 60 | 
             
                  # Start the Covered Experience.
         | 
| 25 61 | 
             
                  #
         | 
| 26 62 | 
             
                  # @yield [self] When a block is provided, the experience will be completed automatically.
         | 
| @@ -41,52 +77,59 @@ module Labkit | |
| 41 77 | 
             
                  #  experience.complete
         | 
| 42 78 | 
             
                  def start(**extra, &)
         | 
| 43 79 | 
             
                    @start_time = Time.now.utc
         | 
| 44 | 
            -
                    checkpoint_counter.increment(checkpoint: "start")
         | 
| 80 | 
            +
                    checkpoint_counter.increment(checkpoint: "start", **base_labels)
         | 
| 45 81 | 
             
                    log_event("start", **extra)
         | 
| 46 82 |  | 
| 83 | 
            +
                    Labkit::CoveredExperience::Current.active_experiences[id] = self
         | 
| 84 | 
            +
             | 
| 47 85 | 
             
                    return self unless block_given?
         | 
| 48 86 |  | 
| 49 | 
            -
                     | 
| 50 | 
            -
                      yield self
         | 
| 51 | 
            -
                      self
         | 
| 52 | 
            -
                    rescue StandardError => e
         | 
| 53 | 
            -
                      error!(e)
         | 
| 54 | 
            -
                      raise
         | 
| 55 | 
            -
                    ensure
         | 
| 56 | 
            -
                      complete(**extra)
         | 
| 57 | 
            -
                    end
         | 
| 87 | 
            +
                    completable(**extra, &)
         | 
| 58 88 | 
             
                  end
         | 
| 59 89 |  | 
| 60 90 | 
             
                  # Checkpoint the Covered Experience.
         | 
| 61 91 | 
             
                  #
         | 
| 62 92 | 
             
                  # @param extra [Hash] Additional data to include in the log event
         | 
| 63 | 
            -
                  # @raise [UnstartedError] If the experience has not been started and RAILS_ENV is development or test.
         | 
| 64 93 | 
             
                  # @return [self]
         | 
| 65 94 | 
             
                  def checkpoint(**extra)
         | 
| 66 | 
            -
                    return unless ensure_started!
         | 
| 95 | 
            +
                    return self unless ensure_started!
         | 
| 67 96 |  | 
| 68 97 | 
             
                    @checkpoint_time = Time.now.utc
         | 
| 69 | 
            -
                    checkpoint_counter.increment(checkpoint: "intermediate")
         | 
| 98 | 
            +
                    checkpoint_counter.increment(checkpoint: "intermediate", **base_labels)
         | 
| 70 99 | 
             
                    log_event("intermediate", **extra)
         | 
| 71 100 |  | 
| 72 101 | 
             
                    self
         | 
| 73 102 | 
             
                  end
         | 
| 74 103 |  | 
| 104 | 
            +
                  # Resume the Covered Experience.
         | 
| 105 | 
            +
                  #
         | 
| 106 | 
            +
                  # @yield [self] When a block is provided, the experience will be completed automatically.
         | 
| 107 | 
            +
                  # @param extra [Hash] Additional data to include in the log
         | 
| 108 | 
            +
                  def resume(**extra, &)
         | 
| 109 | 
            +
                    return self unless ensure_started!
         | 
| 110 | 
            +
             | 
| 111 | 
            +
                    checkpoint(checkpoint_action: 'resume', **extra)
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                    return self unless block_given?
         | 
| 114 | 
            +
             | 
| 115 | 
            +
                    completable(**extra, &)
         | 
| 116 | 
            +
                  end
         | 
| 117 | 
            +
             | 
| 75 118 | 
             
                  # Complete the Covered Experience.
         | 
| 76 119 | 
             
                  #
         | 
| 77 120 | 
             
                  # @param extra [Hash] Additional data to include in the log event
         | 
| 78 | 
            -
                  # @raise [UnstartedError] If the experience has not been started and RAILS_ENV is development or test.
         | 
| 79 121 | 
             
                  # @return [self]
         | 
| 80 122 | 
             
                  def complete(**extra)
         | 
| 81 | 
            -
                    return unless ensure_started!
         | 
| 123 | 
            +
                    return self unless ensure_started! && ensure_incomplete!
         | 
| 82 124 |  | 
| 83 125 | 
             
                    begin
         | 
| 84 126 | 
             
                      @end_time = Time.now.utc
         | 
| 85 127 | 
             
                    ensure
         | 
| 86 | 
            -
                      checkpoint_counter.increment(checkpoint: "end")
         | 
| 87 | 
            -
                      total_counter.increment(error: has_error | 
| 88 | 
            -
                      apdex_counter.increment(success: apdex_success | 
| 128 | 
            +
                      checkpoint_counter.increment(checkpoint: "end", **base_labels)
         | 
| 129 | 
            +
                      total_counter.increment(error: has_error?, **base_labels)
         | 
| 130 | 
            +
                      apdex_counter.increment(success: apdex_success?, **base_labels) unless has_error?
         | 
| 89 131 | 
             
                      log_event("end", **extra)
         | 
| 132 | 
            +
                      Labkit::CoveredExperience::Current.active_experiences.delete(id)
         | 
| 90 133 | 
             
                    end
         | 
| 91 134 |  | 
| 92 135 | 
             
                    self
         | 
| @@ -105,6 +148,12 @@ module Labkit | |
| 105 148 | 
             
                    !!@error
         | 
| 106 149 | 
             
                  end
         | 
| 107 150 |  | 
| 151 | 
            +
                  def to_h
         | 
| 152 | 
            +
                    return {} unless ensure_started!
         | 
| 153 | 
            +
             | 
| 154 | 
            +
                    { id => { "start_time" => @start_time&.iso8601(3) } }
         | 
| 155 | 
            +
                  end
         | 
| 156 | 
            +
             | 
| 108 157 | 
             
                  private
         | 
| 109 158 |  | 
| 110 159 | 
             
                  def base_labels
         | 
| @@ -114,10 +163,28 @@ module Labkit | |
| 114 163 | 
             
                  def ensure_started!
         | 
| 115 164 | 
             
                    return @start_time unless @start_time.nil?
         | 
| 116 165 |  | 
| 117 | 
            -
                     | 
| 166 | 
            +
                    warn("Covered Experience #{@definition.covered_experience} not started")
         | 
| 167 | 
            +
                    false
         | 
| 168 | 
            +
                  end
         | 
| 118 169 |  | 
| 119 | 
            -
             | 
| 120 | 
            -
                     | 
| 170 | 
            +
                  def completable(**extra, &)
         | 
| 171 | 
            +
                    begin
         | 
| 172 | 
            +
                      yield self
         | 
| 173 | 
            +
                    rescue StandardError => e
         | 
| 174 | 
            +
                      error!(e)
         | 
| 175 | 
            +
                      raise
         | 
| 176 | 
            +
                    ensure
         | 
| 177 | 
            +
                      complete(**extra)
         | 
| 178 | 
            +
                    end
         | 
| 179 | 
            +
             | 
| 180 | 
            +
                    self
         | 
| 181 | 
            +
                  end
         | 
| 182 | 
            +
             | 
| 183 | 
            +
                  def ensure_incomplete!
         | 
| 184 | 
            +
                    return true if @end_time.nil?
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                    warn("Covered Experience #{@definition.covered_experience} already completed")
         | 
| 187 | 
            +
                    false
         | 
| 121 188 | 
             
                  end
         | 
| 122 189 |  | 
| 123 190 | 
             
                  def urgency_threshold
         | 
| @@ -125,6 +192,8 @@ module Labkit | |
| 125 192 | 
             
                  end
         | 
| 126 193 |  | 
| 127 194 | 
             
                  def elapsed_time
         | 
| 195 | 
            +
                    return 0 unless @start_time
         | 
| 196 | 
            +
             | 
| 128 197 | 
             
                    last_time = @end_time || @checkpoint_time || @start_time
         | 
| 129 198 | 
             
                    last_time - @start_time
         | 
| 130 199 | 
             
                  end
         | 
| @@ -136,36 +205,35 @@ module Labkit | |
| 136 205 | 
             
                  def checkpoint_counter
         | 
| 137 206 | 
             
                    @checkpoint_counter ||= Labkit::Metrics::Client.counter(
         | 
| 138 207 | 
             
                      :gitlab_covered_experience_checkpoint_total,
         | 
| 139 | 
            -
                      'Total checkpoints for covered experiences' | 
| 140 | 
            -
                      base_labels
         | 
| 208 | 
            +
                      'Total checkpoints for covered experiences'
         | 
| 141 209 | 
             
                    )
         | 
| 142 210 | 
             
                  end
         | 
| 143 211 |  | 
| 144 212 | 
             
                  def total_counter
         | 
| 145 213 | 
             
                    @total_counter ||= Labkit::Metrics::Client.counter(
         | 
| 146 214 | 
             
                      :gitlab_covered_experience_total,
         | 
| 147 | 
            -
                      'Total covered experience events (success/failure)' | 
| 148 | 
            -
                      base_labels
         | 
| 215 | 
            +
                      'Total covered experience events (success/failure)'
         | 
| 149 216 | 
             
                    )
         | 
| 150 217 | 
             
                  end
         | 
| 151 218 |  | 
| 152 219 | 
             
                  def apdex_counter
         | 
| 153 220 | 
             
                    @apdex_counter ||= Labkit::Metrics::Client.counter(
         | 
| 154 221 | 
             
                      :gitlab_covered_experience_apdex_total,
         | 
| 155 | 
            -
                      'Total covered experience apdex events' | 
| 156 | 
            -
                      base_labels
         | 
| 222 | 
            +
                      'Total covered experience apdex events'
         | 
| 157 223 | 
             
                    )
         | 
| 158 224 | 
             
                  end
         | 
| 159 225 |  | 
| 160 226 | 
             
                  def log_event(event_type, **extra)
         | 
| 227 | 
            +
                    validate_extra_parameters!(extra)
         | 
| 228 | 
            +
             | 
| 161 229 | 
             
                    log_data = build_log_data(event_type, **extra)
         | 
| 162 230 | 
             
                    logger.info(log_data)
         | 
| 163 231 | 
             
                  end
         | 
| 164 232 |  | 
| 165 233 | 
             
                  def build_log_data(event_type, **extra)
         | 
| 166 | 
            -
                    log_data =  | 
| 234 | 
            +
                    log_data = ActiveSupport::HashWithIndifferentAccess.new(
         | 
| 167 235 | 
             
                      checkpoint: event_type,
         | 
| 168 | 
            -
                      covered_experience:  | 
| 236 | 
            +
                      covered_experience: id,
         | 
| 169 237 | 
             
                      feature_category: @definition.feature_category,
         | 
| 170 238 | 
             
                      urgency: @definition.urgency,
         | 
| 171 239 | 
             
                      start_time: @start_time,
         | 
| @@ -173,8 +241,8 @@ module Labkit | |
| 173 241 | 
             
                      end_time: @end_time,
         | 
| 174 242 | 
             
                      elapsed_time_s: elapsed_time,
         | 
| 175 243 | 
             
                      urgency_threshold_s: urgency_threshold
         | 
| 176 | 
            -
                     | 
| 177 | 
            -
                    log_data. | 
| 244 | 
            +
                    )
         | 
| 245 | 
            +
                    log_data.reverse_merge!(extra) if extra
         | 
| 178 246 |  | 
| 179 247 | 
             
                    if has_error?
         | 
| 180 248 | 
             
                      log_data[:error] = true
         | 
| @@ -186,8 +254,24 @@ module Labkit | |
| 186 254 | 
             
                    log_data
         | 
| 187 255 | 
             
                  end
         | 
| 188 256 |  | 
| 189 | 
            -
                  def warn( | 
| 190 | 
            -
                     | 
| 257 | 
            +
                  def warn(err, **extra)
         | 
| 258 | 
            +
                    case err
         | 
| 259 | 
            +
                    when StandardError
         | 
| 260 | 
            +
                      logger.warn(component: self.class.name, message: err.message, **extra)
         | 
| 261 | 
            +
                    when String
         | 
| 262 | 
            +
                      logger.warn(component: self.class.name, message: err, **extra)
         | 
| 263 | 
            +
                    end
         | 
| 264 | 
            +
                  end
         | 
| 265 | 
            +
             | 
| 266 | 
            +
                  def validate_extra_parameters!(extra)
         | 
| 267 | 
            +
                    return if extra.empty?
         | 
| 268 | 
            +
             | 
| 269 | 
            +
                    reserved_keys = extra.keys.map(&:to_s) & RESERVED_KEYWORDS
         | 
| 270 | 
            +
                    return if reserved_keys.empty?
         | 
| 271 | 
            +
             | 
| 272 | 
            +
                    err = ReservedKeywordError.new("Reserved keywords found in extra parameters: #{reserved_keys.join(', ')}")
         | 
| 273 | 
            +
             | 
| 274 | 
            +
                    raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
         | 
| 191 275 | 
             
                  end
         | 
| 192 276 |  | 
| 193 277 | 
             
                  def logger
         | 
| @@ -6,9 +6,14 @@ module Labkit | |
| 6 6 | 
             
                class Null
         | 
| 7 7 | 
             
                  include Singleton
         | 
| 8 8 |  | 
| 9 | 
            -
                   | 
| 9 | 
            +
                  def id = 'null'
         | 
| 10 10 |  | 
| 11 | 
            -
                  def start( | 
| 11 | 
            +
                  def start(**_extra)
         | 
| 12 | 
            +
                    yield self if block_given?
         | 
| 13 | 
            +
                    self
         | 
| 14 | 
            +
                  end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  def resume(**_extra)
         | 
| 12 17 | 
             
                    yield self if block_given?
         | 
| 13 18 | 
             
                    self
         | 
| 14 19 | 
             
                  end
         | 
| @@ -17,6 +22,8 @@ module Labkit | |
| 17 22 | 
             
                  def checkpoint(*_args) = self
         | 
| 18 23 | 
             
                  def complete(*_args) = self
         | 
| 19 24 | 
             
                  def error!(*_args) = self
         | 
| 25 | 
            +
                  def rehydrate(*_args, **_kwargs) = self
         | 
| 26 | 
            +
                  def to_h = {}
         | 
| 20 27 | 
             
                end
         | 
| 21 28 | 
             
              end
         | 
| 22 29 | 
             
            end
         | 
| @@ -1,5 +1,6 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            +
            require 'labkit/covered_experience/current'
         | 
| 3 4 | 
             
            require 'labkit/covered_experience/error'
         | 
| 4 5 | 
             
            require 'labkit/covered_experience/experience'
         | 
| 5 6 | 
             
            require 'labkit/covered_experience/null'
         | 
| @@ -15,10 +16,11 @@ module Labkit | |
| 15 16 | 
             
              module CoveredExperience
         | 
| 16 17 | 
             
                # Configuration class for CoveredExperience
         | 
| 17 18 | 
             
                class Configuration
         | 
| 18 | 
            -
                  attr_accessor :logger
         | 
| 19 | 
            +
                  attr_accessor :logger, :registry_path
         | 
| 19 20 |  | 
| 20 21 | 
             
                  def initialize
         | 
| 21 22 | 
             
                    @logger = Labkit::Logging::JsonLogger.new($stdout)
         | 
| 23 | 
            +
                    @registry_path = File.join("config", "covered_experiences")
         | 
| 22 24 | 
             
                  end
         | 
| 23 25 | 
             
                end
         | 
| 24 26 |  | 
| @@ -33,28 +35,45 @@ module Labkit | |
| 33 35 |  | 
| 34 36 | 
             
                  def configure
         | 
| 35 37 | 
             
                    yield(configuration) if block_given?
         | 
| 38 | 
            +
                    # Reset registry when configuration changes to pick up new registry_path
         | 
| 39 | 
            +
                    @registry = nil
         | 
| 36 40 | 
             
                  end
         | 
| 37 41 |  | 
| 38 42 | 
             
                  def registry
         | 
| 39 | 
            -
                    @registry ||= Registry.new
         | 
| 43 | 
            +
                    @registry ||= Registry.new(dir: configuration.registry_path)
         | 
| 40 44 | 
             
                  end
         | 
| 41 45 |  | 
| 42 46 | 
             
                  def reset
         | 
| 43 47 | 
             
                    @registry = nil
         | 
| 48 | 
            +
                    reset_configuration
         | 
| 44 49 | 
             
                  end
         | 
| 45 50 |  | 
| 51 | 
            +
                  # Retrieves a covered experience using the experience_id.
         | 
| 52 | 
            +
                  # It retrieves from the current context when available,
         | 
| 53 | 
            +
                  # otherwise it instantiates a new experience with the definition
         | 
| 54 | 
            +
                  # from the registry.
         | 
| 55 | 
            +
                  #
         | 
| 56 | 
            +
                  # @param experience_id [String, Symbol] The ID of the experience to retrieve.
         | 
| 57 | 
            +
                  # @return [Experience, Null] The found experience or a Null object if not found (in production/staging).
         | 
| 46 58 | 
             
                  def get(experience_id)
         | 
| 47 | 
            -
                     | 
| 59 | 
            +
                    find_current(experience_id) || raise_or_null(experience_id)
         | 
| 60 | 
            +
                  end
         | 
| 48 61 |  | 
| 49 | 
            -
             | 
| 50 | 
            -
             | 
| 51 | 
            -
             | 
| 52 | 
            -
             | 
| 53 | 
            -
             | 
| 62 | 
            +
                  # Starts a covered experience using the experience_id.
         | 
| 63 | 
            +
                  #
         | 
| 64 | 
            +
                  # @param experience_id [String, Symbol] The ID of the experience to start.
         | 
| 65 | 
            +
                  # @param extra [Hash] Additional data to include in the log event.
         | 
| 66 | 
            +
                  # @return [Experience, Null] The started experience or a Null object if not found (in production/staging).
         | 
| 67 | 
            +
                  def start(experience_id, **extra, &)
         | 
| 68 | 
            +
                    get(experience_id).start(**extra, &)
         | 
| 54 69 | 
             
                  end
         | 
| 55 70 |  | 
| 56 | 
            -
                   | 
| 57 | 
            -
             | 
| 71 | 
            +
                  # Resumes a covered experience using the experience_id.
         | 
| 72 | 
            +
                  #
         | 
| 73 | 
            +
                  # @param experience_id [String, Symbol] The ID of the experience to resume.
         | 
| 74 | 
            +
                  # @return [Experience, Null] The started experience or a Null object if not found (in production/staging).
         | 
| 75 | 
            +
                  def resume(experience_id, **extra, &)
         | 
| 76 | 
            +
                    get(experience_id).resume(**extra, &)
         | 
| 58 77 | 
             
                  end
         | 
| 59 78 |  | 
| 60 79 | 
             
                  private
         | 
| @@ -64,6 +83,14 @@ module Labkit | |
| 64 83 |  | 
| 65 84 | 
             
                    raise(NotFoundError, "Covered Experience #{experience_id} not found in the registry")
         | 
| 66 85 | 
             
                  end
         | 
| 86 | 
            +
             | 
| 87 | 
            +
                  def find_current(experience_id)
         | 
| 88 | 
            +
                    xp = Current.active_experiences[experience_id.to_s]
         | 
| 89 | 
            +
                    return xp unless xp.nil?
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                    definition = registry[experience_id]
         | 
| 92 | 
            +
                    Experience.new(definition) if definition
         | 
| 93 | 
            +
                  end
         | 
| 67 94 | 
             
                end
         | 
| 68 95 | 
             
              end
         | 
| 69 96 | 
             
            end
         | 
| @@ -25,6 +25,8 @@ module Labkit | |
| 25 25 |  | 
| 26 26 | 
             
                      [status, headers, response]
         | 
| 27 27 | 
             
                    end
         | 
| 28 | 
            +
                  ensure
         | 
| 29 | 
            +
                    reset_current_experiences
         | 
| 28 30 | 
             
                  end
         | 
| 29 31 |  | 
| 30 32 | 
             
                  private
         | 
| @@ -44,6 +46,27 @@ module Labkit | |
| 44 46 | 
             
                      .merge("version" => "1")
         | 
| 45 47 | 
             
                      .to_json
         | 
| 46 48 | 
             
                  end
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                  # Reset Current experiences to prevent memory leaks and cross-request contamination.
         | 
| 51 | 
            +
                  #
         | 
| 52 | 
            +
                  # Rails applications automatically call ActiveSupport::CurrentAttributes.reset_all
         | 
| 53 | 
            +
                  # after each request via https://github.com/rails/rails/blob/2d4f445051b63853d48c642d0ab59ab026aa1d6a/activesupport/lib/active_support/railtie.rb,
         | 
| 54 | 
            +
                  # but we must handle other scenarios where this middleware might be used:
         | 
| 55 | 
            +
                  #
         | 
| 56 | 
            +
                  # 1. Non-Rails Rack applications (Sinatra, Grape, plain Rack apps)
         | 
| 57 | 
            +
                  # 2. Custom Rack stacks that don't include ActiveSupport::Railtie
         | 
| 58 | 
            +
                  # 3. Test environments where Rails middleware stack might be bypassed
         | 
| 59 | 
            +
                  # 4. Microservices or API-only applications with minimal middleware
         | 
| 60 | 
            +
                  # 5. Background job processors that use Rack but not Rails' full stack
         | 
| 61 | 
            +
                  #
         | 
| 62 | 
            +
                  # By explicitly resetting here, we ensure covered experiences are properly
         | 
| 63 | 
            +
                  # cleaned up regardless of the application framework or middleware configuration.
         | 
| 64 | 
            +
                  def reset_current_experiences
         | 
| 65 | 
            +
                    # Only reset if the Current class is loaded to avoid unnecessary dependencies
         | 
| 66 | 
            +
                    return unless defined?(Labkit::CoveredExperience::Current)
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                    Labkit::CoveredExperience::Current.reset
         | 
| 69 | 
            +
                  end
         | 
| 47 70 | 
             
                end
         | 
| 48 71 | 
             
              end
         | 
| 49 72 | 
             
            end
         | 
| @@ -13,6 +13,7 @@ module Labkit | |
| 13 13 | 
             
                      @chain ||= ::Sidekiq::Middleware::Chain.new do |chain|
         | 
| 14 14 | 
             
                        chain.add Labkit::Middleware::Sidekiq::Context::Client
         | 
| 15 15 | 
             
                        chain.add Labkit::Middleware::Sidekiq::Tracing::Client if Labkit::Tracing.enabled?
         | 
| 16 | 
            +
                        chain.add Labkit::Middleware::Sidekiq::CoveredExperience::Client
         | 
| 16 17 | 
             
                      end
         | 
| 17 18 | 
             
                    end
         | 
| 18 19 |  | 
| @@ -0,0 +1,27 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require 'labkit/covered_experience/current'
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            module Labkit
         | 
| 6 | 
            +
              module Middleware
         | 
| 7 | 
            +
                module Sidekiq
         | 
| 8 | 
            +
                  module CoveredExperience
         | 
| 9 | 
            +
                    # This middleware for Sidekiq-client wraps scheduling jobs with covered
         | 
| 10 | 
            +
                    # experience context. It retrieves the current experiences and
         | 
| 11 | 
            +
                    # populates the job with them.
         | 
| 12 | 
            +
                    class Client
         | 
| 13 | 
            +
                      def call(worker_class, job, _queue, _redis_pool)
         | 
| 14 | 
            +
                        data = Labkit::CoveredExperience::Current.active_experiences.inject({}) do |data, (_, xp)|
         | 
| 15 | 
            +
                          xp.checkpoint(checkpoint_action: "sidekiq_job_scheduled", worker: worker_class.to_s)
         | 
| 16 | 
            +
                          data.merge!(xp.to_h)
         | 
| 17 | 
            +
                        end
         | 
| 18 | 
            +
             | 
| 19 | 
            +
                        job[Labkit::CoveredExperience::Current::AGGREGATION_KEY] = data unless data.empty?
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                        yield
         | 
| 22 | 
            +
                      end
         | 
| 23 | 
            +
                    end
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
                end
         | 
| 26 | 
            +
              end
         | 
| 27 | 
            +
            end
         | 
| @@ -0,0 +1,25 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Labkit
         | 
| 4 | 
            +
              module Middleware
         | 
| 5 | 
            +
                module Sidekiq
         | 
| 6 | 
            +
                  module CoveredExperience
         | 
| 7 | 
            +
                    # This middleware for Sidekiq-server rehydrates the current experiences
         | 
| 8 | 
            +
                    # serialized to the job
         | 
| 9 | 
            +
                    class Server
         | 
| 10 | 
            +
                      def call(_worker_class, job, _queue)
         | 
| 11 | 
            +
                        job[Labkit::CoveredExperience::Current::AGGREGATION_KEY]&.each do |experience_id, data|
         | 
| 12 | 
            +
                          xp = Labkit::CoveredExperience::Current.rehydrate(experience_id, **data)
         | 
| 13 | 
            +
                          xp.checkpoint(checkpoint_action: "sidekiq_job_started", worker: job["class"].to_s)
         | 
| 14 | 
            +
                        end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                        yield
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                      ensure
         | 
| 19 | 
            +
                        Labkit::CoveredExperience::Current.reset
         | 
| 20 | 
            +
                      end
         | 
| 21 | 
            +
                    end
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
              end
         | 
| 25 | 
            +
            end
         | 
| @@ -0,0 +1,14 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Labkit
         | 
| 4 | 
            +
              module Middleware
         | 
| 5 | 
            +
                module Sidekiq
         | 
| 6 | 
            +
                  # This module contains all the sidekiq middleware regarding covered
         | 
| 7 | 
            +
                  # experiences
         | 
| 8 | 
            +
                  module CoveredExperience
         | 
| 9 | 
            +
                    autoload :Client, "labkit/middleware/sidekiq/covered_experience/client"
         | 
| 10 | 
            +
                    autoload :Server, "labkit/middleware/sidekiq/covered_experience/server"
         | 
| 11 | 
            +
                  end
         | 
| 12 | 
            +
                end
         | 
| 13 | 
            +
              end
         | 
| 14 | 
            +
            end
         | 
| @@ -13,6 +13,7 @@ module Labkit | |
| 13 13 | 
             
                      @chain ||= ::Sidekiq::Middleware::Chain.new do |chain|
         | 
| 14 14 | 
             
                        chain.add Labkit::Middleware::Sidekiq::Context::Server
         | 
| 15 15 | 
             
                        chain.add Labkit::Middleware::Sidekiq::Tracing::Server if Labkit::Tracing.enabled?
         | 
| 16 | 
            +
                        chain.add Labkit::Middleware::Sidekiq::CoveredExperience::Server
         | 
| 16 17 | 
             
                      end
         | 
| 17 18 | 
             
                    end
         | 
| 18 19 |  | 
| @@ -7,6 +7,7 @@ module Labkit | |
| 7 7 | 
             
                  autoload :Client, "labkit/middleware/sidekiq/client"
         | 
| 8 8 | 
             
                  autoload :Server, "labkit/middleware/sidekiq/server"
         | 
| 9 9 | 
             
                  autoload :Context, "labkit/middleware/sidekiq/context"
         | 
| 10 | 
            +
                  autoload :CoveredExperience, "labkit/middleware/sidekiq/covered_experience"
         | 
| 10 11 | 
             
                  autoload :Tracing, "labkit/middleware/sidekiq/tracing"
         | 
| 11 12 | 
             
                end
         | 
| 12 13 | 
             
              end
         | 
    
        data/lib/labkit/rspec/README.md
    CHANGED
    
    | @@ -45,6 +45,17 @@ expect { subject }.to checkpoint_covered_experience('rails_request') | |
| 45 45 | 
             
            expect { subject }.not_to checkpoint_covered_experience('rails_request')
         | 
| 46 46 | 
             
            ```
         | 
| 47 47 |  | 
| 48 | 
            +
            #### `resume_covered_experience`
         | 
| 49 | 
            +
             | 
| 50 | 
            +
            Tests that a covered experience is resumed (checkpoint=intermediate metric is incremented). This is an alias for `checkpoint_covered_experience` that provides more semantic meaning when testing code that resumes a covered experience previously started.
         | 
| 51 | 
            +
             | 
| 52 | 
            +
            ```ruby
         | 
| 53 | 
            +
            expect { subject }.to resume_covered_experience('rails_request')
         | 
| 54 | 
            +
             | 
| 55 | 
            +
            # Test that it does NOT resume
         | 
| 56 | 
            +
            expect { subject }.not_to resume_covered_experience('rails_request')
         | 
| 57 | 
            +
            ```
         | 
| 58 | 
            +
             | 
| 48 59 | 
             
            #### `complete_covered_experience`
         | 
| 49 60 |  | 
| 50 61 | 
             
            Tests that a covered experience is completed with the expected metrics:
         | 
| @@ -81,7 +81,7 @@ end | |
| 81 81 | 
             
            #
         | 
| 82 82 | 
             
            # Parameters:
         | 
| 83 83 | 
             
            # - covered_experience_id: Required. The ID of the covered experience (e.g., 'rails_request')
         | 
| 84 | 
            -
            RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id|
         | 
| 84 | 
            +
            RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id, by: 1|
         | 
| 85 85 | 
             
              include Labkit::RSpec::Matchers::CoveredExperience
         | 
| 86 86 |  | 
| 87 87 | 
             
              description { "checkpoint covered experience '#{covered_experience_id}'" }
         | 
| @@ -97,12 +97,15 @@ RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id| | |
| 97 97 | 
             
                checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i
         | 
| 98 98 | 
             
                @checkpoint_change = checkpoint_after - checkpoint_before
         | 
| 99 99 |  | 
| 100 | 
            -
                 | 
| 100 | 
            +
                # Automatic checkpoints can be created in-between depending on the context,
         | 
| 101 | 
            +
                # such as pushing experiences to background jobs. For this reason, we check
         | 
| 102 | 
            +
                # that the value increases by at least "by".
         | 
| 103 | 
            +
                @checkpoint_change >= by
         | 
| 101 104 | 
             
              end
         | 
| 102 105 |  | 
| 103 106 | 
             
              failure_message do
         | 
| 104 107 | 
             
                "Failed to checkpoint covered experience '#{covered_experience_id}':\n" \
         | 
| 105 | 
            -
                  "expected checkpoint='intermediate' counter to increase by  | 
| 108 | 
            +
                  "expected checkpoint='intermediate' counter to increase by at least #{by}, but increased by #{@checkpoint_change}"
         | 
| 106 109 | 
             
              end
         | 
| 107 110 |  | 
| 108 111 | 
             
              match_when_negated do |actual|
         | 
| @@ -120,10 +123,13 @@ RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id| | |
| 120 123 |  | 
| 121 124 | 
             
              failure_message_when_negated do
         | 
| 122 125 | 
             
                "Expected covered experience '#{covered_experience_id}' NOT to checkpoint:\n" \
         | 
| 123 | 
            -
                  "expected checkpoint='intermediate' counter to increase | 
| 126 | 
            +
                  "expected checkpoint='intermediate' counter not to increase, but increased by #{@checkpoint_change}"
         | 
| 124 127 | 
             
              end
         | 
| 125 128 | 
             
            end
         | 
| 126 129 |  | 
| 130 | 
            +
            # Alias for checkpoint_covered_experience matcher
         | 
| 131 | 
            +
            RSpec::Matchers.alias_matcher :resume_covered_experience, :checkpoint_covered_experience
         | 
| 132 | 
            +
             | 
| 127 133 | 
             
            # Matcher for verifying CoveredExperience completion metrics instrumentation.
         | 
| 128 134 | 
             
            #
         | 
| 129 135 | 
             
            # Usage:
         | 
    
        metadata
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: gitlab-labkit
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version: 0. | 
| 4 | 
            +
              version: 0.41.2
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - Andrew Newdigate
         | 
| @@ -460,6 +460,7 @@ files: | |
| 460 460 | 
             
            - lib/labkit/correlation/grpc/server_interceptor.rb
         | 
| 461 461 | 
             
            - lib/labkit/covered_experience.rb
         | 
| 462 462 | 
             
            - lib/labkit/covered_experience/README.md
         | 
| 463 | 
            +
            - lib/labkit/covered_experience/current.rb
         | 
| 463 464 | 
             
            - lib/labkit/covered_experience/error.rb
         | 
| 464 465 | 
             
            - lib/labkit/covered_experience/experience.rb
         | 
| 465 466 | 
             
            - lib/labkit/covered_experience/null.rb
         | 
| @@ -485,6 +486,9 @@ files: | |
| 485 486 | 
             
            - lib/labkit/middleware/sidekiq/context.rb
         | 
| 486 487 | 
             
            - lib/labkit/middleware/sidekiq/context/client.rb
         | 
| 487 488 | 
             
            - lib/labkit/middleware/sidekiq/context/server.rb
         | 
| 489 | 
            +
            - lib/labkit/middleware/sidekiq/covered_experience.rb
         | 
| 490 | 
            +
            - lib/labkit/middleware/sidekiq/covered_experience/client.rb
         | 
| 491 | 
            +
            - lib/labkit/middleware/sidekiq/covered_experience/server.rb
         | 
| 488 492 | 
             
            - lib/labkit/middleware/sidekiq/server.rb
         | 
| 489 493 | 
             
            - lib/labkit/middleware/sidekiq/tracing.rb
         | 
| 490 494 | 
             
            - lib/labkit/middleware/sidekiq/tracing/client.rb
         |