Verified Commit 0b3aba50 authored by Hercules Merscher's avatar Hercules Merscher 🌴
Browse files

feat: RSpec custom matchers for Covered Experience

parent a53fd779
Loading
Loading
Loading
Loading
+3 −2
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ module Labkit
      def start
        @started = Time.now.utc
        checkpoint_counter.increment(checkpoint: "start")
        # complete

        return self unless block_given?

@@ -65,12 +66,12 @@ module Labkit
        @has_error
      end

      private

      def base_labels
        @base_labels ||= @definition.to_h.slice(:id, :feature_category, :urgency)
      end

      private

      def ensure_started!
        return @started unless @started.nil?

+45 −127
Original line number Diff line number Diff line
@@ -3,17 +3,13 @@
require 'spec_helper'
require 'labkit/covered_experience/registry'
require 'labkit/covered_experience/experience'
require_relative '../../support/covered_experience/matchers'

RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
  include StubENV

  let(:definition) do
    Labkit::CoveredExperience::Definition.new(
      id: 'testing_sample',
      description: 'Test experience for specs',
      feature_category: 'test_category',
      urgency: 'sync_fast'
    )
    Labkit::CoveredExperience::Registry.new[:testing_sample]
  end

  subject(:experience) { described_class.new(definition) }
@@ -28,171 +24,113 @@ RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
        expect do |block|
          experience.start(&block)
        end.to yield_with_args(experience)
        .and change {
          checkpoint&.get(experience.base_labels.merge(checkpoint: "start")).to_i
        }.by(1)
        .and change {
          checkpoint&.get(experience.base_labels.merge(checkpoint: "end")).to_i
        }.by(1)
        .and change {
          total&.get(experience.base_labels.merge(error: false)).to_i
        }.by(1)
        .and change {
          apdex&.get(experience.base_labels.merge(success: true)).to_i
        }.by(1)
        .and start_covered_experience(:testing_sample)
        .and complete_covered_experience(:testing_sample)
      end

      it 'captures exceptions and marks as error' do
        expect do
          experience.start { raise 'Something went wrong' }
        end.to raise_error(RuntimeError, 'Something went wrong')
        .and change {
          checkpoint&.get(experience.base_labels.merge(checkpoint: "start")).to_i
        }.by(1)
        .and change {
          checkpoint&.get(experience.base_labels.merge(checkpoint: "end")).to_i
        }.by(1)
        .and change { # rubocop:disable RSpec/ChangeByZero, do not increment apdex for errors
          apdex&.get(experience.base_labels.merge(success: true)).to_i
        }.by(0)
        .and change {
          total&.get(experience.base_labels.merge(error: true)).to_i
        }.by(1)
        .and complete_covered_experience(:testing_sample, error: true)
      end
    end

    context 'when block is not given' do
      it 'returns itself' do
        expect(experience.start).to be(experience)
      end

      it 'marks an experience as started' do
        expect do
          experience.start
        end.to change {
          checkpoint&.get(experience.base_labels.merge(checkpoint: "start")).to_i
        }.by(1)
      subject(:start) { experience.start }

        # do not end the experience
        expect(checkpoint&.get(experience.base_labels.merge(checkpoint: "end")).to_i).to eq 0
        expect(total&.get(experience.base_labels.merge(error: false)).to_i).to eq 0
        expect(apdex&.get(experience.base_labels.merge(success: true)).to_i).to eq 0
      end
      it { is_expected.to be(experience) }
      it { expect { start }.to start_covered_experience(:testing_sample) }
      it { expect { start }.not_to complete_covered_experience(:testing_sample) }
    end
  end

  describe '#checkpoint' do
    subject(:checkpoint) { experience.checkpoint }

    context 'when started' do
      before do
        experience.start
      end

      it 'returns itself' do
        expect(experience.checkpoint).to be(experience)
      end

      it 'records an intermediate checkpoint' do
        expect do
          experience.checkpoint
        end.to change {
          checkpoint&.get(experience.base_labels.merge(checkpoint: "intermediate")).to_i
        }.by(1)
      end
      it { is_expected.to be(experience) }
      it { expect { checkpoint }.to checkpoint_covered_experience(:testing_sample) }
    end

    context 'when not started' do
      it 'does not complete when RAILS_ENV is production' do
      context 'when RAILS_ENV is production' do
        before do
          stub_env('RAILS_ENV', 'production')

        expect { experience.checkpoint }.not_to raise_error

        expect(checkpoint&.get(experience.base_labels.merge(checkpoint: "intermediate")).to_i).to eq 0
        end

      it 'does not complete when RAILS_ENV is unset' do
        expect { experience.checkpoint }.not_to raise_error
        it { expect { checkpoint }.not_to raise_error }
        it { expect { checkpoint }.not_to checkpoint_covered_experience(:testing_sample) }
      end

        expect(checkpoint&.get(experience.base_labels.merge(checkpoint: "intermediate")).to_i).to eq 0
      context 'when RAILS_ENV is unset' do
        it { expect { checkpoint }.not_to raise_error }
        it { expect { checkpoint }.not_to checkpoint_covered_experience(:testing_sample) }
      end

      %w[test development].each do |env|
        it "raises error when RAILS_ENV is #{env}" do
        context "when RAILS_ENV is #{env}" do
          before do
            stub_env('RAILS_ENV', env)
          end

          expect { experience.checkpoint }.to raise_error(RuntimeError, "Covered Experience #{definition.id} not started")
          it { expect { checkpoint }.to raise_error(RuntimeError, "Covered Experience #{definition.id} not started") }
        end
      end
    end
  end

  describe '#complete' do
    subject(:complete) { experience.complete }

    context 'when started' do
      before do
        experience.start
      end

      it 'returns itself' do
        expect(experience.complete).to be(experience)
      end
      it { is_expected.to be(experience) }
      it { expect { complete }.to complete_covered_experience(:testing_sample) }

      it 'records its full completeness' do
        expect do
          experience.complete
        end.to change {
          checkpoint&.get(experience.base_labels.merge(checkpoint: "end")).to_i
        }.by(1)
        .and change {
          total&.get(experience.base_labels.merge(error: false)).to_i
        }.by(1)
        .and change {
          apdex&.get(experience.base_labels.merge(success: true)).to_i
        }.by(1)
      end

      it 'ends with error if previously marked as error' do
      it 'ends with error when marked as error' do
        expect do
          experience.error!.complete
        end.to change {
          total&.get(experience.base_labels.merge(error: true)).to_i
        }.by(1)
        end.to complete_covered_experience(:testing_sample, error: true)
      end

      it 'ends with apdex failure if time elapsed is too long' do
      it 'ends with apdex failure when time elapsed is too long' do
        # simulate the elapsed time in the future
        expect(Time).to receive(:now).and_return(Time.now.utc + 60)

        expect do
          experience.complete
        end.to change {
          apdex&.get(experience.base_labels.merge(success: false)).to_i
        }.by(1)
        expect { complete }.to complete_covered_experience(:testing_sample, success: false)
      end
    end

    context 'when not started' do
      it 'does not complete when RAILS_ENV is production' do
      context 'when RAILS_ENV is production' do
        before do
          stub_env('RAILS_ENV', 'production')

        expect { experience.complete }.not_to raise_error

        expect(checkpoint&.get(experience.base_labels.merge(checkpoint: "end")).to_i).to eq 0
        expect(total&.get(experience.base_labels.merge(error: false)).to_i).to eq 0
        expect(apdex&.get(experience.base_labels.merge(success: true)).to_i).to eq 0
        end

      it 'does not complete when RAILS_ENV is unset' do
        expect { experience.complete }.not_to raise_error
        it { expect { complete }.not_to raise_error }
        it { expect { complete }.not_to complete_covered_experience(:testing_sample) }
      end

        expect(checkpoint&.get(experience.base_labels.merge(checkpoint: "end")).to_i).to eq 0
        expect(total&.get(experience.base_labels.merge(error: false)).to_i).to eq 0
        expect(apdex&.get(experience.base_labels.merge(success: true)).to_i).to eq 0
      context 'when RAILS_ENV is unset' do
        it { expect { complete }.not_to raise_error }
        it { expect { complete }.not_to complete_covered_experience(:testing_sample) }
      end

      %w[test development].each do |env|
        it "raises error when RAILS_ENV is #{env}" do
        context "when RAILS_ENV is #{env}" do
          before do
            stub_env('RAILS_ENV', env)
          end

          expect { experience.complete }.to raise_error(RuntimeError, "Covered Experience #{definition.id} not started")
          it { expect { complete }.to raise_error(RuntimeError, "Covered Experience #{definition.id} not started") }
        end
      end
    end
@@ -204,24 +142,4 @@ RSpec.describe Labkit::CoveredExperience::Experience, :with_metrics_config do
      expect(experience).to have_error
    end
  end

  describe '#base_labels' do
    it 'returns base labels extracted from the definition' do
      expect(experience.base_labels.keys).to include(:id, :feature_category, :urgency)
    end
  end

  private

  def checkpoint
    Labkit::Metrics::Client.get(:gitlab_covered_experience_checkpoint_total)
  end

  def total
    Labkit::Metrics::Client.get(:gitlab_covered_experience_total)
  end

  def apdex
    Labkit::Metrics::Client.get(:gitlab_covered_experience_apdex_total)
  end
end
+184 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

module CoveredExperienceMetrics
  def checkpoint_counter
    Labkit::Metrics::Client.get(:gitlab_covered_experience_checkpoint_total)
  end

  def total_counter
    Labkit::Metrics::Client.get(:gitlab_covered_experience_total)
  end

  def apdex_counter
    Labkit::Metrics::Client.get(:gitlab_covered_experience_apdex_total)
  end

  def base_labels(covered_experience_id)
    raise ArgumentError, "covered_experience_id is required" if covered_experience_id.nil?

    definition = Labkit::CoveredExperience::Registry.new[covered_experience_id]
    definition.to_h.slice(:id, :feature_category, :urgency)
  end
end

# Matcher for verifying CoveredExperience start metrics instrumentation.
#
# Usage:
#   expect { subject }.to start_covered_experience('rails_request')
#
# This matcher verifies that the following metric is incremented:
# - gitlab_covered_experience_checkpoint_total (with checkpoint=start)
#
# Parameters:
# - covered_experience_id: Required. The ID of the covered experience (e.g., 'rails_request')
RSpec::Matchers.define :start_covered_experience do |covered_experience_id|
  include CoveredExperienceMetrics

  description { "start covered experience '#{covered_experience_id}'" }
  supports_block_expectations

  match do |actual|
    labels = base_labels(covered_experience_id)

    checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "start")).to_i

    actual.call

    checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "start")).to_i

    @checkpoint_change = checkpoint_after - checkpoint_before

    @checkpoint_change == 1
  end

  failure_message do
    "Failed to checkpoint covered experience '#{covered_experience_id}':\n" \
      "expected checkpoint='start' counter to increase by 1, but increased by #{@checkpoint_change}"
  end
end

# Matcher for verifying CoveredExperience checkpoint metrics instrumentation.
#
# Usage:
#   expect { subject }.to checkpoint_covered_experience('rails_request')
#
# This matcher verifies that the following metric is incremented:
# - gitlab_covered_experience_checkpoint_total (with checkpoint=intermediate)
#
# Parameters:
# - covered_experience_id: Required. The ID of the covered experience (e.g., 'rails_request')
RSpec::Matchers.define :checkpoint_covered_experience do |covered_experience_id|
  include CoveredExperienceMetrics

  description { "checkpoint covered experience '#{covered_experience_id}'" }
  supports_block_expectations

  match do |actual|
    labels = base_labels(covered_experience_id)

    checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i

    actual.call

    checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i
    @checkpoint_change = checkpoint_after - checkpoint_before

    @checkpoint_change == 1
  end

  failure_message do
    "Failed to checkpoint covered experience '#{covered_experience_id}':\n" \
      "expected checkpoint='intermediate' counter to increase by 1, but increased by #{@checkpoint_change}"
  end

  match_when_negated do |actual|
    labels = base_labels(covered_experience_id)

    checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i

    actual.call

    checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "intermediate")).to_i
    @checkpoint_change = checkpoint_after - checkpoint_before

    @checkpoint_change == 0
  end

  failure_message_when_negated do
    "Expected covered experience '#{covered_experience_id}' NOT to checkpoint:\n" \
      "expected checkpoint='intermediate' counter to increase by 0, but increased by #{@checkpoint_change}"
  end
end

# Matcher for verifying CoveredExperience completion metrics instrumentation.
#
# Usage:
#   expect { subject }.to covered_experience_completed('rails_request')
#
# This matcher verifies that the following metrics are incremented with specific labels:
# - gitlab_covered_experience_checkpoint_total (with checkpoint=end)
# - gitlab_covered_experience_total (with error=false)
# - gitlab_covered_experience_apdex_total (with success=true)
#
# Parameters:
# - covered_experience_id: Required. The ID of the covered experience (e.g., 'rails_request')
# - error: Optional. The expected error flag for gitlab_covered_experience_total (false by default)
# - error: Optional. The expected success flag for gitlab_covered_experience_apdex_total (true by default)
RSpec::Matchers.define :complete_covered_experience do |covered_experience_id, error: false, success: true|
  include CoveredExperienceMetrics

  description { "complete covered experience '#{covered_experience_id}'" }
  supports_block_expectations

  match do |actual|
    labels = base_labels(covered_experience_id)

    checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "end")).to_i
    total_before = total_counter&.get(labels.merge(error: error)).to_i
    apdex_before = apdex_counter&.get(labels.merge(success: success)).to_i

    actual.call

    checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "end")).to_i
    total_after = total_counter&.get(labels.merge(error: error)).to_i
    apdex_after = apdex_counter&.get(labels.merge(success: success)).to_i
    @checkpoint_change = checkpoint_after - checkpoint_before
    @total_change = total_after - total_before
    @apdex_change = apdex_after - apdex_before

    @checkpoint_change == 1 && @total_change == 1 && @apdex_change == (error ? 0 : 1)
  end

  failure_message do
    "Failed to complete covered experience '#{covered_experience_id}':\n" \
      "expected checkpoint='end' counter to increase by 1, but increased by #{@checkpoint_change}\n" \
      "expected total='error: #{error}' counter to increase by 1, but increased by #{@total_change}\n" \
      "expected apdex='success: #{success}' counter to increase by 1, but increased by #{@apdex_change}"
  end

  match_when_negated do |actual|
    labels = base_labels(covered_experience_id)

    checkpoint_before = checkpoint_counter&.get(labels.merge(checkpoint: "end")).to_i
    total_before = total_counter&.get(labels.merge(error: error)).to_i
    apdex_before = apdex_counter&.get(labels.merge(success: success)).to_i

    actual.call

    checkpoint_after = checkpoint_counter&.get(labels.merge(checkpoint: "end")).to_i
    total_after = total_counter&.get(labels.merge(error: error)).to_i
    apdex_after = apdex_counter&.get(labels.merge(success: success)).to_i
    @checkpoint_change = checkpoint_after - checkpoint_before
    @total_change = total_after - total_before
    @apdex_change = apdex_after - apdex_before

    @checkpoint_change == 0 && @total_change == 0 && @apdex_change == (error ? 1 : 0)
  end

  failure_message_when_negated do
    "Failed covered experience '#{covered_experience_id}' NOT to complete:\n" \
      "expected checkpoint='end' counter to increase by 0, but increased by #{@checkpoint_change}\n" \
      "expected total='error: #{error}' counter to increase by 0, but increased by #{@total_change}\n" \
      "expected apdex='success: #{success}' counter to increase by 0, but increased by #{@apdex_change}"
  end
end