Commit 0dc27b96 authored by Bob Van Landuyt's avatar Bob Van Landuyt 💬
Browse files

Merge branch 'feat/propagate-covered-experience' into 'master'

feat: Resume Covered Experiences

Closes gitlab-com/gl-infra/observability/team#4161

See merge request !176

Merged-by: Bob Van Landuyt's avatarBob Van Landuyt <bob@gitlab.com>
Approved-by: Bob Van Landuyt's avatarBob Van Landuyt <bob@gitlab.com>
Reviewed-by: Bob Van Landuyt's avatarBob Van Landuyt <bob@gitlab.com>
Reviewed-by: default avatarJay McCure <jmccure@gitlab.com>
Reviewed-by: Hercules Merscher's avatarHercules Merscher <hmerscher@gitlab.com>
Reviewed-by: default avatarGitLab Duo <gitlab-duo@gitlab.com>
Co-authored-by: Hercules Merscher's avatarHercules Merscher <hmerscher@gitlab.com>
parents bc22fb60 9a75ec30
Loading
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -257,7 +257,7 @@ RSpec/LetBeforeExamples:
# Offense count: 20
# Configuration parameters: AllowSubject.
RSpec/MultipleMemoizedHelpers:
  Max: 7
  Max: 8

# Offense count: 24
# Configuration parameters: EnforcedStyle, IgnoreSharedExamples.
+37 −10
Original line number Diff line number Diff line
# frozen_string_literal: true

require 'labkit/covered_experience/current'
require 'labkit/covered_experience/error'
require 'labkit/covered_experience/experience'
require 'labkit/covered_experience/null'
@@ -15,10 +16,11 @@ module Labkit
  module CoveredExperience
    # Configuration class for CoveredExperience
    class Configuration
      attr_accessor :logger
      attr_accessor :logger, :registry_path

      def initialize
        @logger = Labkit::Logging::JsonLogger.new($stdout)
        @registry_path = File.join("config", "covered_experiences")
      end
    end

@@ -33,28 +35,45 @@ module Labkit

      def configure
        yield(configuration) if block_given?
        # Reset registry when configuration changes to pick up new registry_path
        @registry = nil
      end

      def registry
        @registry ||= Registry.new
        @registry ||= Registry.new(dir: configuration.registry_path)
      end

      def reset
        @registry = nil
        reset_configuration
      end

      # Retrieves a covered experience using the experience_id.
      # It retrieves from the current context when available,
      # otherwise it instantiates a new experience with the definition
      # from the registry.
      #
      # @param experience_id [String, Symbol] The ID of the experience to retrieve.
      # @return [Experience, Null] The found experience or a Null object if not found (in production/staging).
      def get(experience_id)
        definition = registry[experience_id]

        if definition
          Experience.new(definition)
        else
          raise_or_null(experience_id)
        find_current(experience_id) || raise_or_null(experience_id)
      end

      # Starts a covered experience using the experience_id.
      #
      # @param experience_id [String, Symbol] The ID of the experience to start.
      # @param extra [Hash] Additional data to include in the log event.
      # @return [Experience, Null] The started experience or a Null object if not found (in production/staging).
      def start(experience_id, **extra, &)
        get(experience_id).start(**extra, &)
      end

      def start(experience_id, &)
        get(experience_id).start(&)
      # Resumes a covered experience using the experience_id.
      #
      # @param experience_id [String, Symbol] The ID of the experience to resume.
      # @return [Experience, Null] The started experience or a Null object if not found (in production/staging).
      def resume(experience_id, **extra, &)
        get(experience_id).resume(**extra, &)
      end

      private
@@ -64,6 +83,14 @@ module Labkit

        raise(NotFoundError, "Covered Experience #{experience_id} not found in the registry")
      end

      def find_current(experience_id)
        xp = Current.active_experiences[experience_id.to_s]
        return xp unless xp.nil?

        definition = registry[experience_id]
        Experience.new(definition) if definition
      end
    end
  end
end
+51 −0
Original line number Diff line number Diff line
@@ -16,6 +16,23 @@ end

This configuration affects all Covered Experience instances and their logging output.

### Registry Path Configuration

By default, covered experience definitions are loaded from the `config/covered_experiences` directory. You can configure a custom registry path:

```ruby
Labkit::CoveredExperience.configure do |config|
  config.registry_path = "my/custom/path"
end
```

This allows you to:
- Store covered experience definitions in a different directory structure
- Use different paths for different environments
- Organize definitions according to your application's needs

**Note:** The registry is automatically reset when the configuration changes, so the new path takes effect immediately.

### Covered Experience Definitions

Covered experience definitions will be lazy loaded from the default directory (`config/covered_experiences`).
@@ -100,6 +117,40 @@ experience.checkpoint
experience.complete
```

#### Resuming Experiences

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.

Just like the start method, we can use a block to automatically complete a covered experience:

```ruby
# Resume an experience from context (with block)
Labkit::CoveredExperience.resume(:merge_request_creation) do |experience|
  # Continue the work from where it left off
  finalize_merge_request

  # Add checkpoints as needed
  experience.checkpoint

  send_notifications
end
```
Or manually:

```ruby
# Resume an experience from context (manual control)
experience = Labkit::CoveredExperience.resume(:merge_request_creation)

# Continue the work
finalize_merge_request
experience.checkpoint

# Complete the experience
experience.complete
```

**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 (raises `UnstartedError` in development/test environments, or safely ignores in other environments).

### Error Handling

When using the block form, errors are automatically captured:
+34 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require "active_support"
require "labkit/covered_experience/experience"

module Labkit
  module CoveredExperience
    # The `Current` class represents a container for the current set
    # of `Labkit::CoveredExperience::Experience` instances started and
    # not yet completed.
    #
    # It uses `ActiveSupport::CurrentAttributes` to provide a thread-safe way to
    # store and access experiences throughout the request and background job lifecycle.
    #
    # Example usage:
    #   Labkit::CoveredExperience::Current.active_experiences << my_experience
    #   Labkit::CoveredExperience::Current.rehydrate("create_merge_request", "start_time" => "2025-08-22T10:02:15.237Z")
    class Current < ActiveSupport::CurrentAttributes
      AGGREGATION_KEY = 'labkit_covered_experiences'

      attribute :_active_experiences

      def active_experiences
        self._active_experiences ||= {}
      end

      def rehydrate(experience_id, **data)
        instance = Labkit::CoveredExperience.get(experience_id).rehydrate(data)
        active_experiences[instance.id] = instance
        instance
      end
    end
  end
end
+2 −0
Original line number Diff line number Diff line
@@ -4,6 +4,8 @@ module Labkit
  module CoveredExperience
    CoveredExperienceError = Class.new(StandardError)
    UnstartedError = Class.new(CoveredExperienceError)
    CompletedError = Class.new(CoveredExperienceError)
    NotFoundError = Class.new(CoveredExperienceError)
    ReservedKeywordError = Class.new(CoveredExperienceError)
  end
end
Loading