diff --git a/Gemfile b/Gemfile index 3be9c3cd..51b38168 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,4 @@ source "https://rubygems.org" gemspec + +gem "pry-byebug" diff --git a/lib/statsd/instrument.rb b/lib/statsd/instrument.rb index 92de80d4..7b8d7e74 100644 --- a/lib/statsd/instrument.rb +++ b/lib/statsd/instrument.rb @@ -56,19 +56,21 @@ def self.generate_metric_name(metric_name, callee, *args) metric_name.respond_to?(:call) ? metric_name.call(callee, args).gsub('::', '.') : metric_name.gsub('::', '.') end + def self.duration + start = current_timestamp + yield + current_timestamp - start + end + if Process.respond_to?(:clock_gettime) # @private - def self.duration - start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - yield - Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + def self.current_timestamp + Process.clock_gettime(Process::CLOCK_MONOTONIC) end else # @private - def self.duration - start = Time.now - yield - Time.now - start + def self.current_timestamp + Time.now end end @@ -206,8 +208,43 @@ def statsd_remove_measure(method, name) remove_from_method(method, name, :measure) end + def self.count_method( + method_name, + on_success: DEFAULT_ON_SUCCESS, + on_exception: DEFAULT_ON_EXCEPTION, + **metric_options + ) + metric = StatsD::Instrument::Metric.new(metric_options.merge(type: :c)) + + StatsD::Instrument::MethodCounter.new( + method_name, + metric, + on_success: on_success, + on_exception: on_exception, + ) + end + + def self.measure_method( + method_name, + on_success: DEFAULT_ON_SUCCESS, + on_exception: DEFAULT_ON_EXCEPTION, + **metric_options + ) + metric = StatsD::Instrument::Metric.new(metric_options.merge(type: :ms, value: 0)) + + StatsD::Instrument::MethodMeasurer.new( + method_name, + metric, + on_success: on_success, + on_exception: on_exception, + ) + end + private + DEFAULT_ON_SUCCESS = -> (_metric, _result) {} + DEFAULT_ON_EXCEPTION = -> (metric, _ex) { metric.discard } + def statsd_instrumentation_for(method, name, action) unless statsd_instrumentations.key?([method, name, action]) mod = Module.new do @@ -403,5 +440,7 @@ def collect_metric(options) require 'statsd/instrument/helpers' require 'statsd/instrument/assertions' require 'statsd/instrument/metric_expectation' +require 'statsd/instrument/method_counter' +require 'statsd/instrument/method_measurer' require 'statsd/instrument/matchers' if defined?(::RSpec) require 'statsd/instrument/railtie' if defined?(Rails) diff --git a/lib/statsd/instrument/method_counter.rb b/lib/statsd/instrument/method_counter.rb new file mode 100644 index 00000000..9b50f7c3 --- /dev/null +++ b/lib/statsd/instrument/method_counter.rb @@ -0,0 +1,67 @@ +module StatsD + module Instrument + class MethodCounter < Module + attr_reader(:method_name) + + def initialize( + method_name, + metric, + on_success:, + on_exception: + ) + @method_name = method_name + @metric = metric + @on_success = on_success + @on_exception = on_exception + end + + def prepend_features(mod) + super(mod) + preserve_visibility(mod, method_name) do + define_method_lifecycle(@metric, @on_success, @on_exception) + end + end + + def inspect + "#<#{self.class.name}[#{method_name.inspect}]>" + end + + private + + def define_method_lifecycle(metric, on_success, on_exception) + define_method(method_name) do |*args, &block| + begin + result = super(*args, &block) + on_success.call(metric, result) + result + rescue => ex + begin + on_exception.call(metric, ex) + ensure + raise(ex) + end + ensure + StatsD.backend.collect_metric(metric) unless metric.discarded? + end + end + end + + def preserve_visibility(mod, method_name) + original_visibility = method_visibility(mod, method_name) + yield + __send__(original_visibility, method_name) + end + + def method_visibility(mod, method) + case + when mod.private_method_defined?(method) + :private + when mod.protected_method_defined?(method) + :protected + else + :public + end + end + end + end +end diff --git a/lib/statsd/instrument/method_measurer.rb b/lib/statsd/instrument/method_measurer.rb new file mode 100644 index 00000000..bb3170c7 --- /dev/null +++ b/lib/statsd/instrument/method_measurer.rb @@ -0,0 +1,28 @@ +module StatsD + module Instrument + class MethodMeasurer < MethodCounter + private + + def define_method_lifecycle(metric, on_success, on_exception) + define_method(method_name) do |*args, &block| + start = StatsD::Instrument.current_timestamp + begin + result = super(*args, &block) + metric.value = StatsD::Instrument.current_timestamp - start + on_success.call(metric, result) + result + rescue => ex + metric.value = StatsD::Instrument.current_timestamp - start + begin + on_exception.call(metric, ex) + ensure + raise(ex) + end + ensure + StatsD.backend.collect_metric(metric) unless metric.discarded? + end + end + end + end + end +end diff --git a/lib/statsd/instrument/metric.rb b/lib/statsd/instrument/metric.rb index 82e8ae2a..0f2807b9 100644 --- a/lib/statsd/instrument/metric.rb +++ b/lib/statsd/instrument/metric.rb @@ -47,7 +47,7 @@ def initialize(options = {}) @type = options[:type] or raise ArgumentError, "Metric :type is required." @name = options[:name] or raise ArgumentError, "Metric :name is required." @name = StatsD.prefix ? "#{StatsD.prefix}.#{@name}" : @name unless options[:no_prefix] - + @discarded = false @value = options[:value] || default_value @sample_rate = options[:sample_rate] || StatsD.default_sample_rate @tags = StatsD::Instrument::Metric.normalize_tags(options[:tags]) @@ -82,6 +82,14 @@ def inspect "#" end + def discard + @discarded = true + end + + def discarded? + !!@discarded + end + # The metric types that are supported by this library. Note that every StatsD server # implementation only supports a subset of them. TYPES = { diff --git a/test/method_counter_test.rb b/test/method_counter_test.rb new file mode 100644 index 00000000..51722887 --- /dev/null +++ b/test/method_counter_test.rb @@ -0,0 +1,197 @@ +require 'test_helper' + +class MethodCounterTest < Minitest::Test + include StatsD::Instrument::Assertions + + class ToBeInstrumented + class << self + def to_be_counted_too + :return_value + end + + def to_raise_too + raise "💥" + end + + def to_raise_too_with_suffix + raise StandardError.new("boom!") + end + + def to_be_counted_too_with_suffix + :return_value + end + + protected + + def i_am_protected_too + :return_value + end + + private + + def i_am_private_too + :return_value + end + end + + def to_be_counted + :return_value + end + + def to_be_counted_with_suffix + :return_value + end + + def to_raise + raise "💥" + end + + def to_raise_with_suffix + raise StandardError.new("boom!") + end + + def inspect + '#' + end + + protected + + def i_am_protected + :return_value + end + + private + + def i_am_private + :return_value + end + end + + SuffixedExcpetionHandler = -> (metric, ex) { metric.name = "#{metric.name}.#{ex.class}" } + SuffixedHandler = -> (metric, result) { metric.name = "#{metric.name}.#{result}" } + + ToBeInstrumented.prepend StatsD::Instrument.count_method(:i_am_protected, name: "i_am_protected") + ToBeInstrumented.prepend StatsD::Instrument.count_method(:i_am_private, name: "i_am_private") + ToBeInstrumented.prepend StatsD::Instrument.count_method(:to_raise, name: "to_raise") + ToBeInstrumented.prepend StatsD::Instrument.count_method(:to_raise_with_suffix, name: "to_raise_with_suffix", on_exception: SuffixedExcpetionHandler) + ToBeInstrumented.prepend StatsD::Instrument.count_method(:to_be_counted_with_suffix, name: "to_be_counted_with_suffix", on_success: SuffixedHandler) + ToBeInstrumented.prepend StatsD::Instrument.count_method(:to_be_counted, name: "to_be_counted") + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.count_method(:i_am_protected, name: "i_am_protected_too") + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.count_method(:i_am_private, name: "i_am_private_too") + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.count_method(:to_raise_too, name: "to_raise_too") + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.count_method(:to_raise_too_with_suffix, name: "to_raise_too_with_suffix", on_exception: SuffixedExcpetionHandler) + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.count_method(:to_be_counted_too_with_suffix, name: "to_be_counted_too_with_suffix", on_success: SuffixedHandler) + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.count_method(:to_be_counted_too, name: "to_be_counted_too") + + def test_instrumented_method_increments_statsd_counter_when_called + assert_statsd_increment('to_be_counted') do + return_value = ToBeInstrumented.new.to_be_counted + assert_equal :return_value, return_value + end + end + + def test_instrumented_method_increments_statsd_counter_when_called_and_appends_suffix + assert_statsd_increment('to_be_counted_with_suffix.return_value') do + return_value = ToBeInstrumented.new.to_be_counted_with_suffix + assert_equal :return_value, return_value + end + end + + def test_instrumented_method_is_discarded_on_exceptions + assert_no_statsd_calls do + assert_raises do + ToBeInstrumented.new.to_raise + end + end + end + + def test_instrumented_method_add_suffix_on_exceptions + assert_statsd_increment('to_raise_with_suffix.StandardError') do + assert_raises do + ToBeInstrumented.new.to_raise_with_suffix + end + end + end + + def test_instrumented_class_method_increments_statsd_counter_when_called + assert_statsd_increment('to_be_counted_too') do + return_value = ToBeInstrumented.to_be_counted_too + assert_equal :return_value, return_value + end + end + + def test_instrumented_class_method_increments_statsd_counter_when_called_and_appends_suffix + assert_statsd_increment('to_be_counted_too_with_suffix.return_value') do + return_value = ToBeInstrumented.to_be_counted_too_with_suffix + assert_equal :return_value, return_value + end + end + + def test_instrumented_class_method_is_discarded_on_exceptions + assert_no_statsd_calls do + assert_raises do + ToBeInstrumented.to_raise_too + end + end + end + + def test_instrumented_class_method_add_suffix_on_exceptions + assert_statsd_increment('to_raise_too_with_suffix.StandardError') do + assert_raises do + ToBeInstrumented.to_raise_too_with_suffix + end + end + end + + def test_instance_method_ancestors + counter_module = ToBeInstrumented.ancestors.first + assert_instance_of StatsD::Instrument::MethodCounter, counter_module + assert_equal :to_be_counted, counter_module.method_name + assert_equal '#', counter_module.inspect + end + + def test_singleton_class_method_ancestors + counter_module = ToBeInstrumented.singleton_class.ancestors.first + assert_instance_of StatsD::Instrument::MethodCounter, counter_module + assert_equal :to_be_counted_too, counter_module.method_name + assert_equal '#', counter_module.inspect + end + + def test_no_littering_in_instrumented_class + refute_includes ToBeInstrumented.new.methods, :count_method + refute_includes ToBeInstrumented.new.methods, :method_name + refute_includes ToBeInstrumented.methods, :count_method + refute_includes ToBeInstrumented.methods, :method_name + + assert_equal '#', ToBeInstrumented.new.inspect + assert_equal 'MethodCounterTest::ToBeInstrumented', ToBeInstrumented.inspect + end + + def test_instance_method_preserved_visibility + assert ToBeInstrumented.private_method_defined?(:i_am_private) + refute ToBeInstrumented.protected_method_defined?(:i_am_private) + refute ToBeInstrumented.public_method_defined?(:i_am_private) + + refute ToBeInstrumented.private_method_defined?(:i_am_protected) + assert ToBeInstrumented.protected_method_defined?(:i_am_protected) + refute ToBeInstrumented.public_method_defined?(:i_am_protected) + + refute ToBeInstrumented.private_method_defined?(:to_be_counted) + refute ToBeInstrumented.protected_method_defined?(:to_be_counted) + assert ToBeInstrumented.public_method_defined?(:to_be_counted) + end + + def test_class_methods_preserved_visibility + assert ToBeInstrumented.singleton_class.private_method_defined?(:i_am_private_too) + refute ToBeInstrumented.singleton_class.protected_method_defined?(:i_am_private_too) + refute ToBeInstrumented.singleton_class.public_method_defined?(:i_am_private_too) + + refute ToBeInstrumented.singleton_class.private_method_defined?(:i_am_protected_too) + assert ToBeInstrumented.singleton_class.protected_method_defined?(:i_am_protected_too) + refute ToBeInstrumented.singleton_class.public_method_defined?(:i_am_protected_too) + + refute ToBeInstrumented.singleton_class.private_method_defined?(:to_be_counted_too) + refute ToBeInstrumented.singleton_class.protected_method_defined?(:to_be_counted_too) + assert ToBeInstrumented.singleton_class.public_method_defined?(:to_be_counted_too) + end +end diff --git a/test/method_measurer_test.rb b/test/method_measurer_test.rb new file mode 100644 index 00000000..fe8b201d --- /dev/null +++ b/test/method_measurer_test.rb @@ -0,0 +1,197 @@ +require 'test_helper' + +class MethodMeasurerTest < Minitest::Test + include StatsD::Instrument::Assertions + + class ToBeInstrumented + class << self + def to_be_measured_too + :return_value + end + + def to_raise_too + raise "💥" + end + + def to_raise_too_with_suffix + raise StandardError.new("boom!") + end + + def to_be_measured_too_with_suffix + :return_value + end + + protected + + def i_am_protected_too + :return_value + end + + private + + def i_am_private_too + :return_value + end + end + + def to_be_measured + :return_value + end + + def to_be_measured_with_suffix + :return_value + end + + def to_raise + raise "💥" + end + + def to_raise_with_suffix + raise StandardError.new("boom!") + end + + def inspect + '#' + end + + protected + + def i_am_protected + :return_value + end + + private + + def i_am_private + :return_value + end + end + + SuffixedExcpetionHandler = -> (metric, ex) { metric.name = "#{metric.name}.#{ex.class}" } + SuffixedHandler = -> (metric, result) { metric.name = "#{metric.name}.#{result}" } + + ToBeInstrumented.prepend StatsD::Instrument.measure_method(:i_am_protected, name: "i_am_protected") + ToBeInstrumented.prepend StatsD::Instrument.measure_method(:i_am_private, name: "i_am_private") + ToBeInstrumented.prepend StatsD::Instrument.measure_method(:to_raise, name: "to_raise") + ToBeInstrumented.prepend StatsD::Instrument.measure_method(:to_raise_with_suffix, name: "to_raise_with_suffix", on_exception: SuffixedExcpetionHandler) + ToBeInstrumented.prepend StatsD::Instrument.measure_method(:to_be_measured_with_suffix, name: "to_be_measured_with_suffix", on_success: SuffixedHandler) + ToBeInstrumented.prepend StatsD::Instrument.measure_method(:to_be_measured, name: "to_be_measured") + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.measure_method(:i_am_protected, name: "i_am_protected_too") + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.measure_method(:i_am_private, name: "i_am_private_too") + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.measure_method(:to_raise_too, name: "to_raise_too") + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.measure_method(:to_raise_too_with_suffix, name: "to_raise_too_with_suffix", on_exception: SuffixedExcpetionHandler) + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.measure_method(:to_be_measured_too_with_suffix, name: "to_be_measured_too_with_suffix", on_success: SuffixedHandler) + ToBeInstrumented.singleton_class.prepend StatsD::Instrument.measure_method(:to_be_measured_too, name: "to_be_measured_too") + + def test_instrumented_method_increments_statsd_counter_when_called + assert_statsd_measure('to_be_measured') do + return_value = ToBeInstrumented.new.to_be_measured + assert_equal :return_value, return_value + end + end + + def test_instrumented_method_increments_statsd_counter_when_called_and_appends_suffix + assert_statsd_measure('to_be_measured_with_suffix.return_value') do + return_value = ToBeInstrumented.new.to_be_measured_with_suffix + assert_equal :return_value, return_value + end + end + + def test_instrumented_method_is_discarded_on_exceptions + assert_no_statsd_calls do + assert_raises do + ToBeInstrumented.new.to_raise + end + end + end + + def test_instrumented_method_add_suffix_on_exceptions + assert_statsd_measure('to_raise_with_suffix.StandardError') do + assert_raises do + ToBeInstrumented.new.to_raise_with_suffix + end + end + end + + def test_instrumented_class_method_increments_statsd_counter_when_called + assert_statsd_measure('to_be_measured_too') do + return_value = ToBeInstrumented.to_be_measured_too + assert_equal :return_value, return_value + end + end + + def test_instrumented_class_method_increments_statsd_counter_when_called_and_appends_suffix + assert_statsd_measure('to_be_measured_too_with_suffix.return_value') do + return_value = ToBeInstrumented.to_be_measured_too_with_suffix + assert_equal :return_value, return_value + end + end + + def test_instrumented_class_method_is_discarded_on_exceptions + assert_no_statsd_calls do + assert_raises do + ToBeInstrumented.to_raise_too + end + end + end + + def test_instrumented_class_method_add_suffix_on_exceptions + assert_statsd_measure('to_raise_too_with_suffix.StandardError') do + assert_raises do + ToBeInstrumented.to_raise_too_with_suffix + end + end + end + + def test_instance_method_ancestors + measurer_module = ToBeInstrumented.ancestors.first + assert_instance_of StatsD::Instrument::MethodMeasurer, measurer_module + assert_equal :to_be_measured, measurer_module.method_name + assert_equal '#', measurer_module.inspect + end + + def test_singleton_class_method_ancestors + measurer_module = ToBeInstrumented.singleton_class.ancestors.first + assert_instance_of StatsD::Instrument::MethodMeasurer, measurer_module + assert_equal :to_be_measured_too, measurer_module.method_name + assert_equal '#', measurer_module.inspect + end + + def test_no_littering_in_instrumented_class + refute_includes ToBeInstrumented.new.methods, :count_method + refute_includes ToBeInstrumented.new.methods, :method_name + refute_includes ToBeInstrumented.methods, :count_method + refute_includes ToBeInstrumented.methods, :method_name + + assert_equal '#', ToBeInstrumented.new.inspect + assert_equal 'MethodMeasurerTest::ToBeInstrumented', ToBeInstrumented.inspect + end + + def test_instance_method_preserved_visibility + assert ToBeInstrumented.private_method_defined?(:i_am_private) + refute ToBeInstrumented.protected_method_defined?(:i_am_private) + refute ToBeInstrumented.public_method_defined?(:i_am_private) + + refute ToBeInstrumented.private_method_defined?(:i_am_protected) + assert ToBeInstrumented.protected_method_defined?(:i_am_protected) + refute ToBeInstrumented.public_method_defined?(:i_am_protected) + + refute ToBeInstrumented.private_method_defined?(:to_be_measured) + refute ToBeInstrumented.protected_method_defined?(:to_be_measured) + assert ToBeInstrumented.public_method_defined?(:to_be_measured) + end + + def test_class_methods_preserved_visibility + assert ToBeInstrumented.singleton_class.private_method_defined?(:i_am_private_too) + refute ToBeInstrumented.singleton_class.protected_method_defined?(:i_am_private_too) + refute ToBeInstrumented.singleton_class.public_method_defined?(:i_am_private_too) + + refute ToBeInstrumented.singleton_class.private_method_defined?(:i_am_protected_too) + assert ToBeInstrumented.singleton_class.protected_method_defined?(:i_am_protected_too) + refute ToBeInstrumented.singleton_class.public_method_defined?(:i_am_protected_too) + + refute ToBeInstrumented.singleton_class.private_method_defined?(:to_be_measured_too) + refute ToBeInstrumented.singleton_class.protected_method_defined?(:to_be_measured_too) + assert ToBeInstrumented.singleton_class.public_method_defined?(:to_be_measured_too) + end +end