diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ad31a..3660793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +## 0.12.0 - 2023-07-28 + +### Added + +- Summary metric type (mostly for Prometheus adapter). + ## 0.11.0 - 2021-09-25 ### Added diff --git a/README.md b/README.md index a565254..1f73f64 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ And then execute: comment "How long whistles are being active" unit :seconds end + summary :bells_ringing_duration, unit: :seconds, comment: "How long bells are ringing" end end ``` @@ -181,7 +182,7 @@ Add the following to your `rails_helper.rb` (or `spec_helper.rb`): require "yabeda/rspec" ``` -Now you can use `increment_yabeda_counter`, `update_yabeda_gauge`, and `measure_yabeda_histogram` matchers: +Now you can use `increment_yabeda_counter`, `update_yabeda_gauge`, `measure_yabeda_histogram`, and `observe_yabeda_summary` matchers: ```ruby it "increments counters" do @@ -201,7 +202,7 @@ end Note that tags you specified doesn't need to be exact, but can be a subset of tags used on metric update. In this example updates with following sets of tags `{ method: "command", command: "subscribe", status: "SUCCESS" }` and `{ method: "command", command: "subscribe", status: "FAILURE" }` will make test example to pass. -And check for values with `by` for counters, `to` for gauges, and `with` for gauges and histograms (and you [can use other matchers here](https://relishapp.com/rspec/rspec-expectations/v/3-10/docs/composing-matchers)): +And check for values with `by` for counters, `to` for gauges, and `with` for histograms and summaries (and you [can use other matchers here](https://relishapp.com/rspec/rspec-expectations/v/3-10/docs/composing-matchers)): ```ruby expect { subject }.to \ diff --git a/lib/yabeda/base_adapter.rb b/lib/yabeda/base_adapter.rb index 0e9f660..ac4ff0d 100644 --- a/lib/yabeda/base_adapter.rb +++ b/lib/yabeda/base_adapter.rb @@ -8,6 +8,7 @@ def register!(metric) when Counter then register_counter!(metric) when Gauge then register_gauge!(metric) when Histogram then register_histogram!(metric) + when Summary then register_summary!(metric) else raise "#{metric.class} is unknown metric type" end end @@ -36,6 +37,14 @@ def perform_histogram_measure!(_metric, _tags, _value) raise NotImplementedError, "#{self.class} doesn't support measuring histograms" end + def register_summary!(_metric) + raise NotImplementedError, "#{self.class} doesn't support summaries as metric type!" + end + + def perform_summary_observe!(_metric, _tags, _value) + raise NotImplementedError, "#{self.class} doesn't support observing summaries" + end + # Hook to enable debug mode in adapters when it is enabled in Yabeda itself def debug!; end end diff --git a/lib/yabeda/dsl/class_methods.rb b/lib/yabeda/dsl/class_methods.rb index c8dd70c..dc00ded 100644 --- a/lib/yabeda/dsl/class_methods.rb +++ b/lib/yabeda/dsl/class_methods.rb @@ -4,6 +4,7 @@ require "yabeda/counter" require "yabeda/gauge" require "yabeda/histogram" +require "yabeda/summary" require "yabeda/group" require "yabeda/global_group" require "yabeda/dsl/metric_builder" @@ -55,6 +56,12 @@ def histogram(*args, **kwargs, &block) register_metric(metric) end + # Register a summary + def summary(*args, **kwargs, &block) + metric = MetricBuilder.new(Summary).build(args, kwargs, @group, &block) + register_metric(metric) + end + # Add default tag for all metric # # @param name [Symbol] Name of default tag diff --git a/lib/yabeda/rspec.rb b/lib/yabeda/rspec.rb index fea9784..deed416 100644 --- a/lib/yabeda/rspec.rb +++ b/lib/yabeda/rspec.rb @@ -11,6 +11,7 @@ module RSpec require_relative "rspec/increment_yabeda_counter" require_relative "rspec/update_yabeda_gauge" require_relative "rspec/measure_yabeda_histogram" +require_relative "rspec/observe_yabeda_summary" RSpec.configure do |config| config.before(:suite) do diff --git a/lib/yabeda/rspec/observe_yabeda_summary.rb b/lib/yabeda/rspec/observe_yabeda_summary.rb new file mode 100644 index 0000000..b50ae7d --- /dev/null +++ b/lib/yabeda/rspec/observe_yabeda_summary.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "base_matcher" + +module Yabeda + module RSpec + # Checks whether Yabeda summary was observed during test run or not + # @param metric [Yabeda::Summary,String,Symbol] metric instance or name + # @return [Yabeda::RSpec::ObserveYabedaSummary] + def observe_yabeda_summary(metric) + ObserveYabedaSummary.new(metric) + end + + # Custom matcher class with implementation for +observe_yabeda_summary+ + class ObserveYabedaSummary < BaseMatcher + def with(value) + @expected_value = value + self + end + + attr_reader :expected_value + + def initialize(*) + super + return if metric.is_a? Yabeda::Summary + + raise ArgumentError, "Pass summary instance/name to `observe_yabeda_summary`. Got #{metric.inspect} instead" + end + + def match(metric, block) + block.call + + observations = filter_matching_changes(Yabeda::TestAdapter.instance.summaries.fetch(metric)) + + observations.values.any? { |observation| expected_value.nil? || values_match?(expected_value, observation) } + end + + def match_when_negated(metric, block) + unless expected_value.nil? + raise NotImplementedError, <<~MSG + `expect {}.not_to observe_yabeda_summary` doesn't support specifying values with `.with` + as it can lead to false positives. + MSG + end + + block.call + + observations = filter_matching_changes(Yabeda::TestAdapter.instance.summaries.fetch(metric)) + + observations.none? + end + + def failure_message + "expected #{expected_formatted} " \ + "to be observed #{"with #{expected} " unless expected_value.nil?}" \ + "#{"with tags #{::RSpec::Support::ObjectFormatter.format(tags)} " if tags}" \ + "but #{actual_changes_message}" + end + + def failure_message_when_negated + "expected #{expected_formatted} " \ + "not to be observed " \ + "#{"with tags #{::RSpec::Support::ObjectFormatter.format(tags)} " if tags}" \ + "but #{actual_changes_message}" + end + + def actual_changes_message + observations = Yabeda::TestAdapter.instance.summaries.fetch(metric) + if observations.empty? + "no observations of this summary have been made" + elsif tags && observations.key?(tags) + formatted_tags = ::RSpec::Support::ObjectFormatter.format(tags) + "has been observed with #{observations.fetch(tags)} with tags #{formatted_tags}" + else + "following observations have been made: #{::RSpec::Support::ObjectFormatter.format(observations)}" + end + end + end + end +end diff --git a/lib/yabeda/summary.rb b/lib/yabeda/summary.rb new file mode 100644 index 0000000..4edd708 --- /dev/null +++ b/lib/yabeda/summary.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Yabeda + # Base class for complex metric for measuring time values that allow to + # calculate averages, percentiles, and so on. + class Summary < Metric + # rubocop: disable Metrics/MethodLength + def observe(tags, value = nil) + if value.nil? ^ block_given? + raise ArgumentError, "You must provide either numeric value or block for Yabeda::Summary#observe!" + end + + if block_given? + starting = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + value = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - starting) + end + + all_tags = ::Yabeda::Tags.build(tags, group) + values[all_tags] = value + ::Yabeda.adapters.each do |_, adapter| + adapter.perform_summary_observe!(self, all_tags, value) + end + value + end + # rubocop: enable Metrics/MethodLength + end +end diff --git a/lib/yabeda/test_adapter.rb b/lib/yabeda/test_adapter.rb index 7131a66..e56743e 100644 --- a/lib/yabeda/test_adapter.rb +++ b/lib/yabeda/test_adapter.rb @@ -9,18 +9,21 @@ module Yabeda class TestAdapter < BaseAdapter include Singleton - attr_reader :counters, :gauges, :histograms + attr_reader :counters, :gauges, :histograms, :summaries + # rubocop:disable Metrics/AbcSize def initialize super @counters = Hash.new { |ch, ck| ch[ck] = Hash.new { |th, tk| th[tk] = 0 } } @gauges = Hash.new { |gh, gk| gh[gk] = Hash.new { |th, tk| th[tk] = nil } } @histograms = Hash.new { |hh, hk| hh[hk] = Hash.new { |th, tk| th[tk] = nil } } + @summaries = Hash.new { |sh, sk| sh[sk] = Hash.new { |th, tk| th[tk] = nil } } end + # rubocop:enable Metrics/AbcSize # Call this method after every test example to quickly get blank state for the next test example def reset! - [@counters, @gauges, @histograms].each do |collection| + [@counters, @gauges, @histograms, @summaries].each do |collection| collection.each_value(&:clear) # Reset tag-values hash to be empty end end @@ -37,6 +40,10 @@ def register_histogram!(metric) @histograms[metric] end + def register_summary!(metric) + @summaries[metric] + end + def perform_counter_increment!(counter, tags, increment) @counters[counter][tags] += increment end @@ -48,5 +55,9 @@ def perform_gauge_set!(gauge, tags, value) def perform_histogram_measure!(histogram, tags, value) @histograms[histogram][tags] = value end + + def perform_summary_observe!(summary, tags, value) + @summaries[summary][tags] = value + end end end diff --git a/lib/yabeda/version.rb b/lib/yabeda/version.rb index 8922940..1d1e3b7 100644 --- a/lib/yabeda/version.rb +++ b/lib/yabeda/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Yabeda - VERSION = "0.11.0" + VERSION = "0.12.0" end diff --git a/spec/yabeda/dsl/class_methods_spec.rb b/spec/yabeda/dsl/class_methods_spec.rb index 1d9f965..3b2fe9c 100644 --- a/spec/yabeda/dsl/class_methods_spec.rb +++ b/spec/yabeda/dsl/class_methods_spec.rb @@ -96,6 +96,19 @@ end end + describe ".summary" do + subject { Yabeda.test_summary } + + context "when properly configured" do + before do + Yabeda.configure { summary(:test_summary) } + Yabeda.configure! unless Yabeda.already_configured? + end + + it("defines method on root object") { is_expected.to be_a(Yabeda::Summary) } + end + end + describe ".default_tag" do subject { Yabeda.default_tags } diff --git a/spec/yabeda/rspec/observe_yabeda_summary_spec.rb b/spec/yabeda/rspec/observe_yabeda_summary_spec.rb new file mode 100644 index 0000000..1230636 --- /dev/null +++ b/spec/yabeda/rspec/observe_yabeda_summary_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "yabeda/rspec" + +RSpec.describe "Yabeda RSpec matchers" do + before do + Yabeda.reset! + Yabeda.configure do + summary :test_summary + summary :other_summary + end + Yabeda.register_adapter(:test, Yabeda::TestAdapter.instance) + Yabeda.configure! + end + + describe "#observe_yabeda_summary" do + it "succeeds when given summary was updated by any value" do + expect do + Yabeda.test_summary.observe({}, 42) + end.to observe_yabeda_summary(Yabeda.test_summary) + end + + it "fails when given summary wasn't updated" do + expect do + expect do + # nothing here + end.to observe_yabeda_summary(Yabeda.test_summary) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end + + it "fails when any other summary was updated" do + expect do + expect do + Yabeda.other_summary.observe({}, 0.001) + end.to observe_yabeda_summary(Yabeda.test_summary) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end + + context "with value specified" do + it "succeeds when given summary was updated by exact value" do + expect do + Yabeda.test_summary.observe({}, 42) + end.to observe_yabeda_summary(Yabeda.test_summary).with(42) + end + + it "succeeds when given summary was updated by matching value" do + expect do + Yabeda.test_summary.observe({}, 2) + end.to observe_yabeda_summary(Yabeda.test_summary).with(be_even) + end + + it "fails when given summary was updated with any other value" do + expect do + expect do + Yabeda.other_summary.observe({}, 1) + end.to observe_yabeda_summary(Yabeda.test_summary).with(2) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end + end + + context "with tags specified" do + it "succeeds when tags are match" do + expect do + Yabeda.test_summary.observe({ foo: :bar }, 42) + end.to observe_yabeda_summary(Yabeda.test_summary).with_tags(foo: :bar) + end + + it "fails when tags doesn't match" do + expect do + expect do + Yabeda.other_summary.observe({ foo: :bar, baz: :qux }, 15) + end.to observe_yabeda_summary(Yabeda.test_summary).with_tags(foo: :bar, baz: :boom) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end + + it "succeeds when subset of tags was specified and it matches" do + expect do + expect do + Yabeda.other_summary.observe({ foo: :bar, baz: :qux }, 0.001) + end.to observe_yabeda_summary(Yabeda.test_summary).with_tags(foo: :bar) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end + end + + context "with negated expect" do + it "succeeds when given summary wasn't updated" do + expect do + # nothing here + end.not_to observe_yabeda_summary(Yabeda.test_summary) + end + + it "fails when given summary was updated" do + expect do + expect do + Yabeda.test_summary.observe({}, 42) + end.not_to observe_yabeda_summary(Yabeda.test_summary) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end + + context "with set specified" do + it "throws error as this behavior can lead to too permissive tests" do + expect do + expect do + Yabeda.test_summary.observe({}, 42) + end.not_to observe_yabeda_summary(Yabeda.test_summary).with(42) + end.to raise_error(NotImplementedError) + end + end + + context "with tags specified" do + it "fails when tags are match" do + expect do + expect do + Yabeda.test_summary.observe({ foo: :bar }, 42) + end.not_to observe_yabeda_summary(Yabeda.test_summary).with_tags(foo: :bar) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end + + it "succeeds when tags doesn't match" do + expect do + Yabeda.test_summary.observe({ foo: :bar, baz: :qux }, 5) + end.not_to observe_yabeda_summary(Yabeda.test_summary).with_tags(foo: :bar, baz: :boom) + end + + it "fails when subset of tags was specified and set tags matches this subset" do + expect do + expect do + Yabeda.test_summary.observe({ foo: :bar, baz: :qux }, 0.001) + end.not_to observe_yabeda_summary(Yabeda.test_summary).with_tags(foo: :bar) + end.to raise_error(RSpec::Expectations::ExpectationNotMetError) + end + end + end + + it "allows to pass summary name instead of metric object" do + expect do + Yabeda.test_summary.observe({}, 0.013) + end.to observe_yabeda_summary(:test_summary).with(0.01..0.02) + end + end +end diff --git a/spec/yabeda/summary_spec.rb b/spec/yabeda/summary_spec.rb new file mode 100644 index 0000000..0a27dae --- /dev/null +++ b/spec/yabeda/summary_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +RSpec.describe Yabeda::Summary do + subject(:observe_summary) { summary.observe(tags, metric_value) } + + let(:tags) { { foo: "bar" } } + let(:metric_value) { 10 } + let(:block) { proc { 1 + 1 } } + let(:summary) { Yabeda.test_summary } + let(:built_tags) { { built_foo: "built_bar" } } + let(:adapter) { instance_double(Yabeda::BaseAdapter, perform_summary_observe!: true, register!: true) } + + before do + Yabeda.configure { summary :test_summary } + Yabeda.configure! unless Yabeda.already_configured? + allow(Yabeda::Tags).to receive(:build).with(tags, anything).and_return(built_tags) + Yabeda.register_adapter(:test_adapter, adapter) + end + + context "with value given" do + it { is_expected.to eq(metric_value) } + + it "execute perform_summary_observe! method of adapter" do + observe_summary + expect(adapter).to have_received(:perform_summary_observe!).with(summary, built_tags, metric_value) + end + end + + context "with block given" do + subject(:observe_summary) { summary.observe(tags, &block) } + + let(:block) { proc { sleep(0.02) } } + + it { is_expected.to be_between(0.01, 0.05) } # Ruby can sleep more or less than requested + + it "execute perform_summary_observe! method of adapter" do + observe_summary + expect(adapter).to have_received(:perform_summary_observe!).with(summary, built_tags, be_between(0.01, 0.05)) + end + end + + context "with both value and block provided" do + subject(:observe_summary) { summary.observe(tags, metric_value, &block) } + + it "raises an argument error" do + expect { observe_summary }.to raise_error(ArgumentError) + end + end + + context "with both value and block omitted" do + subject(:observe_summary) { summary.observe(tags) } + + it "raises an argument error" do + expect { observe_summary }.to raise_error(ArgumentError) + end + end +end