Verified Commit 8cd8de81 authored by Hercules Merscher's avatar Hercules Merscher
Browse files

feat: Labkit::CoveredExperience.resume

parent 3f18dd58
Loading
Loading
Loading
Loading
+6 −2
Original line number Diff line number Diff line
@@ -53,8 +53,12 @@ module Labkit
        end
      end

      def start(experience_id, &)
        get(experience_id).start(&)
      def start(experience_id, **extra, &)
        get(experience_id).start(**extra, &)
      end

      def resume(experience_id, **extra, &)
        get(experience_id).resume(**extra, &)
      end

      private
+31 −0
Original line number Diff line number Diff line
@@ -100,6 +100,37 @@ 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:

```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
```

```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 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:
+49 −2
Original line number Diff line number Diff line
@@ -58,6 +58,27 @@ module Labkit
        end
      end

      # Resume the Covered Experience from the context.
      #
      # @yield [self] When a block is provided, the experience will be completed automatically.
      # @param extra [Hash] Additional data to include in the log
      def resume(**extra, &)
        load_from_context(**extra)
        ensure_started!

        return self unless block_given?

        begin
          yield self
          self
        rescue StandardError => e
          error!(e)
          raise
        ensure
          complete(**extra)
        end
      end

      # Checkpoint the Covered Experience.
      #
      # @param extra [Hash] Additional data to include in the log event
@@ -210,8 +231,34 @@ module Labkit
        Labkit::Context.push(Labkit::Context::COVERED_EXPERIENCE_AGG_KEY => covered_experiences)
      end

      def warn(exception)
        logger.warn(component: self.class.name, message: exception.message)
      def load_from_context(**extra)
        covered_experiences = aggregation_context
        experience_data = covered_experiences[@definition.covered_experience]

        processed_data =
          case experience_data
          when Proc then experience_data.call
          when String then experience_data
          else experience_data || {}
          end

        if processed_data["start_time"].nil?
          warn("#{@definition.covered_experience} cannot not be resumed", covered_experiences: covered_experiences)
          return
        end

        @start_time = Time.iso8601(processed_data["start_time"])
        checkpoint_counter.increment(checkpoint: "intermediate")
        log_event("intermediate", checkpoint_category: "resume", **extra)
      end

      def warn(err, **extra)
        case err
        when StandardError
          logger.warn(component: self.class.name, message: err.message, **extra)
        when String
          logger.warn(component: self.class.name, message: err, **extra)
        end
      end

      def logger
+6 −1
Original line number Diff line number Diff line
@@ -8,7 +8,12 @@ module Labkit

      attr_reader :id, :description, :feature_category, :urgency

      def start(*_args)
      def start(**_extra)
        yield self if block_given?
        self
      end

      def resume(**_extra)
        yield self if block_given?
        self
      end
+183 −8
Original line number Diff line number Diff line
@@ -201,7 +201,7 @@ RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
          expect do |block|
            experience.resume(&block)
          end.to yield_with_args(experience)
          .and resume_covered_experience(:testing_sample)
          .and checkpoint_covered_experience(:testing_sample)
          .and complete_covered_experience(:testing_sample)
        end

@@ -209,13 +209,13 @@ RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
          expect do
            experience.resume { raise 'Something went wrong' }
          end.to raise_error(RuntimeError, 'Something went wrong')
          .and resume_covered_experience(:testing_sample)
          .and checkpoint_covered_experience(:testing_sample)
          .and complete_covered_experience(:testing_sample, error: true)
        end

        it 'logs resume and end events' do
          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(**event_info, checkpoint: 'resume'))
            .with(hash_including(**event_info, checkpoint: 'intermediate', checkpoint_category: 'resume'))
            .ordered
            .and_call_original

@@ -231,7 +231,8 @@ RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(
              **event_info,
              checkpoint: 'resume',
              checkpoint: 'intermediate',
              checkpoint_category: 'resume',
              session_id: 'session-789'
            ))
            .ordered
@@ -255,12 +256,12 @@ RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
        subject(:resume) { experience.resume }

        it { is_expected.to be(experience) }
        it { expect { resume }.to resume_covered_experience(:testing_sample) }
        it { expect { resume }.to checkpoint_covered_experience(:testing_sample) }
        it { expect { resume }.not_to complete_covered_experience(:testing_sample) }

        it 'logs only resume event' do
          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(**event_info, checkpoint: 'resume'))
            .with(hash_including(**event_info, checkpoint: 'intermediate', checkpoint_category: 'resume'))
            .and_call_original

          resume
@@ -276,13 +277,187 @@ RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(
              **event_info,
              checkpoint: 'resume',
              checkpoint: 'intermediate',
              checkpoint_category: 'resume',
              resumed_by: 'worker-123'
            ))
            .and_call_original

          experience.resume(resumed_by: 'worker-123')
        end
      end
    end

    context 'when experience was started in the same process' do
      let(:started_experience) { described_class.new(definition) }

      before do
        # Start an experience in the same process, which will store a proc in context
        started_experience.start
      end

      context 'when block is given' do
        it 'returns itself' do
          expect(experience.resume { |_xp| 1 + 1 }).to be(experience)
        end

        it 'resumes and automatically completes the experience' do
          # When resuming from same process, we expect 2 checkpoint increments:
          # 1. From calling the proc (push_to_context)
          # 2. From the resume itself
          expect do |block|
            experience.resume(&block)
          end.to yield_with_args(experience)
          .and complete_covered_experience(:testing_sample)

          # Verify that 2 checkpoint increments occurred
          labels = definition.to_h.slice(:covered_experience, :feature_category, :urgency)
          checkpoint_counter = Labkit::Metrics::Client.get(:gitlab_covered_experience_checkpoint_total)
          expect(checkpoint_counter.get(labels.merge(checkpoint: "intermediate"))).to be >= 2
        end

        it 'captures exceptions and marks as error' do
          # When resuming from same process, we expect 2 checkpoint increments:
          # 1. From calling the proc (push_to_context)
          # 2. From the resume itself
          expect do
            experience.resume { raise 'Something went wrong' }
          end.to raise_error(RuntimeError, 'Something went wrong')
          .and complete_covered_experience(:testing_sample, error: true)

          # Verify that 2 checkpoint increments occurred
          labels = definition.to_h.slice(:covered_experience, :feature_category, :urgency)
          checkpoint_counter = Labkit::Metrics::Client.get(:gitlab_covered_experience_checkpoint_total)
          expect(checkpoint_counter.get(labels.merge(checkpoint: "intermediate"))).to be >= 2
        end

        it 'logs resume and end events' do
          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(**event_info, checkpoint: 'intermediate', checkpoint_category: 'push_to_context'))
            .ordered
            .and_call_original

          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(**event_info, checkpoint: 'intermediate', checkpoint_category: 'resume'))
            .ordered
            .and_call_original

          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(**event_info, checkpoint: 'end', end_time: a_kind_of(Time)))
            .ordered
            .and_call_original

          experience.resume { |_xp| 1 + 1 }
        end

        it 'includes extra arguments in log events' do
          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(
              **event_info,
              checkpoint: 'intermediate',
              checkpoint_category: 'push_to_context'
            ))
            .ordered
            .and_call_original

          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(
              **event_info,
              checkpoint: 'intermediate',
              checkpoint_category: 'resume',
              session_id: 'session-789'
            ))
            .ordered
            .and_call_original

          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(
              **event_info,
              checkpoint: 'end',
              end_time: a_kind_of(Time),
              session_id: 'session-789'
            ))
            .ordered
            .and_call_original

          experience.resume(session_id: 'session-789') { |_xp| 1 + 1 }
        end
      end

      context 'when block is not given' do
        subject(:resume) { experience.resume }

        it { is_expected.to be(experience) }

        it 'increments checkpoint counter twice (proc call + resume)' do
          # When resuming from same process, we expect 2 checkpoint increments:
          # 1. From calling the proc (push_to_context)
          # 2. From the resume itself
          labels = definition.to_h.slice(:covered_experience, :feature_category, :urgency)
          checkpoint_counter = Labkit::Metrics::Client.get(:gitlab_covered_experience_checkpoint_total)

          before_count = checkpoint_counter.get(labels.merge(checkpoint: "intermediate")).to_i
          resume
          after_count = checkpoint_counter.get(labels.merge(checkpoint: "intermediate")).to_i

          expect(after_count - before_count).to eq(2)
        end

        it { expect { resume }.not_to complete_covered_experience(:testing_sample) }

        it 'logs resume event after calling the proc' do
          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(**event_info, checkpoint: 'intermediate', checkpoint_category: 'push_to_context'))
            .ordered
            .and_call_original

          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(**event_info, checkpoint: 'intermediate', checkpoint_category: 'resume'))
            .ordered
            .and_call_original

          resume
        end

        it 'loads start time from the started experience' do
          resume

          original_start_time = started_experience.start_time
          expect(experience.start_time).to be_within(1).of(original_start_time)
        end

        it 'includes extra arguments in log event' do
          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(
              **event_info,
              checkpoint: 'intermediate',
              checkpoint_category: 'push_to_context'
            ))
            .ordered
            .and_call_original

          expect(Labkit::CoveredExperience.configuration.logger).to receive(:info)
            .with(hash_including(
              **event_info,
              checkpoint: 'intermediate',
              checkpoint_category: 'resume',
              resumed_by: 'worker-123'
            ))
            .ordered
            .and_call_original

          experience.resume(resumed_by: 'worker-123')
        end

        it 'calls the proc stored in context to get experience data' do
          # The proc should be called when loading from context
          covered_experiences = Labkit::Context.current.get_attribute("covered_experiences")
          proc_object = covered_experiences["testing_sample"]

          expect(proc_object).to receive(:call).and_call_original

          resume
        end
      end
    end

@@ -305,7 +480,7 @@ RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
        end

        it 'does not resume the experience' do
          expect { experience.resume }.not_to resume_covered_experience(:testing_sample)
          expect { experience.resume }.not_to checkpoint_covered_experience(:testing_sample)
        end
      end

Loading