Loading lib/labkit/covered_experience.rb +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 Loading @@ -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 lib/labkit/covered_experience/experience.rb 0 → 100644 +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 lib/labkit/covered_experience/null.rb 0 → 100644 +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 spec/fixtures/covered_experiences/invalid.yml 0 → 100644 +3 −0 Original line number Diff line number Diff line invalid: covered: experience: "here" spec/labkit/covered_experience/experience_spec.rb 0 → 100644 +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
lib/labkit/covered_experience.rb +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 Loading @@ -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
lib/labkit/covered_experience/experience.rb 0 → 100644 +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
lib/labkit/covered_experience/null.rb 0 → 100644 +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
spec/fixtures/covered_experiences/invalid.yml 0 → 100644 +3 −0 Original line number Diff line number Diff line invalid: covered: experience: "here"
spec/labkit/covered_experience/experience_spec.rb 0 → 100644 +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