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

feat: Covered Experience API

parent 4a221fd4
Loading
Loading
Loading
Loading
+20 −5
Original line number Diff line number Diff line
# frozen_string_literal: true

require "forwardable"
require 'labkit/covered_experience/registry'
require 'labkit/covered_experience/experience'
require 'labkit/covered_experience/null'

module Labkit
  # Module for loading and managing Covered Experiences.
  module CoveredExperience
    class << self
      extend Forwardable

      def_delegators :registry, :[]

      def registry
        @registry ||= Registry.new
      end
@@ -18,6 +15,24 @@ module Labkit
      def reset
        @registry = nil
      end

      def [](experience_id)
        definition = registry[experience_id]

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

      private

      def raise_or_null(experience_id)
        return Null.instance unless %w[development test].include?(ENV['RAILS_ENV'])

        raise "Covered Experience #{experience_id} not found in the registry"
      end
    end
  end
end
+120 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require 'labkit/logging/json_logger'

module Labkit
  module CoveredExperience
    URGENCY_THRESHOLDS_IN_SECONDS = {
      sync_fast: 2,
      sync_slow: 5,
      async_fast: 15,
      async_slow: 300
    }.freeze

    class Experience
      def initialize(definition)
        @definition = definition
        @has_error = false
      end

      def start
        @started = Time.now.utc
        checkpoint_counter.increment(checkpoint: "start")

        return self unless block_given?

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

      def checkpoint
        return unless ensure_started!

        checkpoint_counter.increment(checkpoint: "intermediate")
        self
      end

      def complete
        return unless ensure_started!

        begin
          elapsed = Time.now.utc - @started
        ensure
          checkpoint_counter.increment(checkpoint: "end")
          total_counter.increment(error: has_error?)
          apdex_counter.increment(success: elapsed <= urgency_threshold) unless has_error?
        end

        self
      end

      def error!(error = nil)
        @has_error = true
        @error = error
        self
      end

      def has_error?
        @has_error
      end

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

      private

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

        err = "Covered Experience #{@definition.id} not started"

        warn(err)
        raise(err) if %w[development test].include?(ENV['RAILS_ENV'])
      end

      def urgency_threshold
        URGENCY_THRESHOLDS_IN_SECONDS[@definition.urgency.to_sym]
      end

      def checkpoint_counter
        @checkpoint_counter ||= Labkit::Metrics::Client.counter(
          :gitlab_covered_experience_checkpoint_total,
          'Total checkpoints for covered experiences',
          base_labels
        )
      end

      def total_counter
        @total_counter ||= Labkit::Metrics::Client.counter(
          :gitlab_covered_experience_total,
          'Total covered experience events (success/failure)',
          base_labels
        )
      end

      def apdex_counter
        @apdex_counter ||= Labkit::Metrics::Client.counter(
          :gitlab_covered_experience_apdex_total,
          'Total covered experience apdex events',
          base_labels
        )
      end

      def warn(message)
        logger.warn(component: self.class.name, message: message)
      end

      def logger
        @logger ||= Labkit::Logging::JsonLogger.new($stdout)
      end
    end
  end
end
+22 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

module Labkit
  module CoveredExperience
    # Fakes Labkit::CoveredExperience::Experience.
    class Null
      include Singleton

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

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

      def push_attributes!(*_args) = self
      def checkpoint(*_args) = self
      def complete(*_args) = self
      def error!(*_args) = self
    end
  end
end
+3 −0
Original line number Diff line number Diff line
invalid:
  covered:
    experience: "here"
+227 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require 'spec_helper'
require 'labkit/covered_experience/registry'
require 'labkit/covered_experience/experience'

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'
    )
  end

  subject(:experience) { described_class.new(definition) }

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

      it 'starts and automatically ends the experience' 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)
      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)
      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)

        # 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
    end
  end

  describe '#checkpoint' do
    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
    end

    context 'when not started' do
      it 'does not complete when RAILS_ENV is production' 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

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

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

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

  describe '#complete' do
    context 'when started' do
      before do
        experience.start
      end

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

      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
        expect do
          experience.error!.complete
        end.to change {
          total&.get(experience.base_labels.merge(error: true)).to_i
        }.by(1)
      end

      it 'ends with apdex failure if 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)
      end
    end

    context 'when not started' do
      it 'does not complete when RAILS_ENV is production' 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

        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

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

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

  describe '#error!' do
    it 'returns itself' do
      expect(experience.error!).to be(experience)
      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
Loading