├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── Rakefile ├── Readme.org ├── advisor.gemspec ├── bin └── console ├── lib ├── advisor.rb └── advisor │ ├── advices.rb │ ├── advices │ ├── call_logger.rb │ └── metriks.rb │ ├── builtin_advisors.rb │ ├── factory.rb │ └── version.rb └── spec ├── advisor ├── advices │ ├── call_logger_spec.rb │ └── metriks_spec.rb ├── builtin_advisors_spec.rb └── factory_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Specific to RubyMotion: 13 | .dat* 14 | .repl_history 15 | build/ 16 | 17 | ## Documentation cache and generated files: 18 | /.yardoc/ 19 | /_yardoc/ 20 | /doc/ 21 | /rdoc/ 22 | 23 | ## Environment normalisation: 24 | /.bundle/ 25 | /lib/bundler/man/ 26 | 27 | # for a library or gem, you might want to ignore these files since the code is 28 | # intended to run in multiple environments; otherwise, check them in: 29 | # Gemfile.lock 30 | # .ruby-version 31 | # .ruby-gemset 32 | 33 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 34 | .rvmrc 35 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Documentation: 2 | Enabled: false 3 | 4 | Style/Encoding: 5 | Enabled: false 6 | 7 | Style/MultilineOperationIndentation: 8 | Enabled: false 9 | 10 | AllCops: 11 | Include: 12 | - '**/*.gemspec' 13 | - '**/Rakefile' 14 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.1.5 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - '2.0.0' 4 | - '2.1.0' 5 | - '2.1.5' 6 | - '2.2' 7 | script: bundle exec rspec 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | advisor (0.6.0) 5 | metriks 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.0.0) 11 | astrolabe (1.3.0) 12 | parser (>= 2.2.0.pre.3, < 3.0) 13 | atomic (1.1.99) 14 | avl_tree (1.2.1) 15 | atomic (~> 1.1) 16 | byebug (3.5.1) 17 | columnize (~> 0.8) 18 | debugger-linecache (~> 1.2) 19 | slop (~> 3.6) 20 | coderay (1.1.0) 21 | columnize (0.9.0) 22 | debugger-linecache (1.2.0) 23 | diff-lcs (1.2.5) 24 | hitimes (1.2.4) 25 | method_source (0.8.2) 26 | metriks (0.9.9.7) 27 | atomic (~> 1.0) 28 | avl_tree (~> 1.2.0) 29 | hitimes (~> 1.1) 30 | parser (2.2.0.3) 31 | ast (>= 1.1, < 3.0) 32 | powerpack (0.1.0) 33 | pry (0.10.1) 34 | coderay (~> 1.1.0) 35 | method_source (~> 0.8.1) 36 | slop (~> 3.4) 37 | pry-byebug (3.0.0) 38 | byebug (~> 3.4) 39 | pry (~> 0.10) 40 | rainbow (2.0.0) 41 | rake (10.4.2) 42 | rspec (3.2.0) 43 | rspec-core (~> 3.2.0) 44 | rspec-expectations (~> 3.2.0) 45 | rspec-mocks (~> 3.2.0) 46 | rspec-core (3.2.3) 47 | rspec-support (~> 3.2.0) 48 | rspec-expectations (3.2.1) 49 | diff-lcs (>= 1.2.0, < 2.0) 50 | rspec-support (~> 3.2.0) 51 | rspec-mocks (3.2.1) 52 | diff-lcs (>= 1.2.0, < 2.0) 53 | rspec-support (~> 3.2.0) 54 | rspec-support (3.2.2) 55 | rubocop (0.30.0) 56 | astrolabe (~> 1.3) 57 | parser (>= 2.2.0.1, < 3.0) 58 | powerpack (~> 0.1) 59 | rainbow (>= 1.99.1, < 3.0) 60 | ruby-progressbar (~> 1.4) 61 | ruby-progressbar (1.7.5) 62 | slop (3.6.0) 63 | 64 | PLATFORMS 65 | ruby 66 | 67 | DEPENDENCIES 68 | advisor! 69 | bundler (~> 1.7) 70 | pry 71 | pry-byebug 72 | rake (~> 10.0) 73 | rspec (~> 3.0) 74 | rubocop 75 | 76 | BUNDLED WITH 77 | 1.12.1 78 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | task deploy: [:build] do 4 | # sh "git tag #{Advisor::VERSION}" 5 | sh 'bundle install' 6 | sh 'gem push pkg/*.gem' 7 | sh 'rm -rf pkg/*.gem' 8 | sh 'git add Gemfile.lock && git commit --amend --no-edit' 9 | sh 'git push && git push --tags' 10 | end 11 | -------------------------------------------------------------------------------- /Readme.org: -------------------------------------------------------------------------------- 1 | * Advisor: Solve your cross-cutting concerns without mumbo-jumbo. 2 | 3 | [[https://travis-ci.org/rranelli/advisor.svg?branch=master][https://travis-ci.org/rranelli/advisor.svg]] 4 | 5 | =Advisor= is Ruby gem that enables you to solve cross-cutting concerns without 6 | the usual =method-renaming= present in most alternatives. 7 | 8 | =Advisor= intercepts method calls and allows you to mix cross cutting concerns 9 | and tedious book-keeping tasks. Logging, metric reporting, auditing, timing, 10 | timeouting can be handled beautifully. 11 | 12 | =Advisor= works with plain Ruby modules and do not mess your stack trace. 13 | 14 | Also, the amount of /intrusion/ required to set up is kept to a minimum while 15 | still keeping it /discoverable/. Every affected class must explicitly extend a 16 | given module and every affected method call must also be declared. 17 | 18 | *** Usage 19 | 20 | =Advisor= is organized between two main concepts only: =Advisor modules= and 21 | =Advice modules=. =Advisor modules= are extensions applied to your classes 22 | and =Advice modules= define the actual behavior of the intercepted method 23 | calls. 24 | 25 | In order to understand better how =Advisor= works, we are going to use an 26 | example: 27 | 28 | ***** Example 29 | 30 | Suppose you want to log calls to some methods but don't want to keep 31 | repeating the message formatting or messing with the method body. 32 | =Advisor= provides a simple built-in module called =Advisor::Loggable= 33 | that solves this issue. 34 | 35 | #+begin_src ruby 36 | class Account 37 | extend Advisor::Loggable 38 | 39 | log_calls_to :deposit 40 | 41 | def deposit(_amount, _origin) 42 | #... 43 | :done 44 | end 45 | end 46 | #+end_src 47 | 48 | In an interactive console: 49 | 50 | #+begin_src ruby 51 | $ Account.new.deposit(300, 'Jane Doe') 52 | # => I, [2015-04-11T21:26:42.405180 #13840] INFO -- : [Time=2015-04-11 21:26:42 -0300][Thread=70183196300040]Called: Account#deposit(300, "Jane Doe") 53 | # => :done 54 | #+end_src 55 | 56 | As you can see, the method call is intercepted and a message is printed to 57 | =stdout=. 58 | 59 | =Advisor= achieves this by using Ruby 2.0's =Module#prepend=. If you were 60 | to check =Account='s ancestors you would get: 61 | 62 | #+begin_src ruby 63 | $ Account.ancestors 64 | # => [Advisor::Advices::CallLogger(deposit), Account, Object, Kernel, BasicObject] 65 | #+end_src 66 | 67 | As you can see, the =Advisor::Advices::CallLogger(deposit)= module is 68 | listed *before* Account itself in the ancestor chain. 69 | 70 | In the next session we are going to explain how to write your own custom 71 | advice. 72 | 73 | ***** Writing an =Advice= 74 | 75 | An =Advice= defines what to do with the advised method call. 76 | 77 | The required interface for an advice must be like the example bellow: 78 | 79 | #+begin_src ruby 80 | class Advice 81 | def initialize(receiver, advised_method, call_args, **options) 82 | # The constructor of an advice must receive 3 arguments and extra options. 83 | # Those extra options are defined when applying the extension to the advised 84 | # class. 85 | end 86 | 87 | def self.applier_method 88 | # Must return the name of the method which must be called in the class body 89 | # to define which methods will be intercepted with the advice. 90 | 91 | # In the case of `Advisor::Loggable`, this method returns `:log_calls_to` 92 | end 93 | 94 | def call 95 | # This is the body of the advice. 96 | # 97 | # This method will always be called with the block `{ super(*call_args, 98 | # &blk) }` That means the method implementation can decide when to run the 99 | # advised method call. Check `Advisor::Advices::CallLogger` for an example. 100 | end 101 | end 102 | #+end_src 103 | 104 | ***** Creating an =Advisor= module 105 | 106 | Every =Advisor= module must be built from the corresponding =Advice= by 107 | using the =Advisor::Factory#build= method. 108 | 109 | =Advisor::Loggable= is built from the =Advisor::Advices::CallLogger= 110 | module. 111 | 112 | =Advisor::Loggable= itself is built like this: 113 | 114 | #+begin_src ruby 115 | module Advisor 116 | Loggable = Factory.new(Advices::CallLogger).build 117 | end 118 | #+end_src 119 | 120 | Hence, if your custom =Advice= complies to the required interface, 121 | =Advisor::Factory= will be able to convert it to an extension module with 122 | no problems. 123 | 124 | *** Disclaimer 125 | 126 | This version of the library is still experimental and probably not 127 | production ready. Use at your own risk. 128 | -------------------------------------------------------------------------------- /advisor.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | require 'advisor/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'advisor' 8 | spec.version = Advisor::VERSION 9 | spec.required_ruby_version = '~>2.0' 10 | 11 | spec.summary = 'AOP with anonymous modules' 12 | spec.authors = ['Renan Ranelli'] 13 | spec.email = ['renanranelli@gmail.com'] 14 | spec.homepage = 'http://github.com/rranelli/advisor' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files -z`.split("\x0") 18 | spec.executables = spec.files.grep(%r{^bin\/}) { |f| File.basename(f) } 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_dependency 'metriks' 22 | 23 | spec.add_development_dependency 'bundler', '~> 1.7' 24 | spec.add_development_dependency 'rspec', '~> 3.0' 25 | spec.add_development_dependency 'rake', '~> 10.0' 26 | spec.add_development_dependency 'rubocop' 27 | spec.add_development_dependency 'pry' 28 | spec.add_development_dependency 'pry-byebug' 29 | end 30 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'advisor' 5 | 6 | require 'pry' 7 | Pry.start 8 | -------------------------------------------------------------------------------- /lib/advisor.rb: -------------------------------------------------------------------------------- 1 | require_relative 'advisor/version' 2 | require_relative 'advisor/advices' 3 | require_relative 'advisor/factory' 4 | require_relative 'advisor/builtin_advisors' 5 | -------------------------------------------------------------------------------- /lib/advisor/advices.rb: -------------------------------------------------------------------------------- 1 | require_relative 'advices/call_logger' 2 | require_relative 'advices/metriks' 3 | -------------------------------------------------------------------------------- /lib/advisor/advices/call_logger.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module Advisor 4 | module Advices 5 | # A simple built-in logging advise 6 | # 7 | # == Examples 8 | # 9 | # class MyClass 10 | # extend Advisor::Loggable 11 | # 12 | # log_calls_to(:simple) 13 | # # [...]Called: MyClass#simple() 14 | # 15 | # log_calls_to(:with_result, result: true) 16 | # # [...]Called: MyClass#with_result() 17 | # # [...]Result: MyClass#with_result() => String: "bla" 18 | # 19 | # log_calls_to(:with_tag, tag: -> { "[id=#{id}]" } 20 | # # [...][id=42]Called: MyClass#with_tag() 21 | # 22 | # log_calls_to(:with_specific_logger, logger: Rails.logger) 23 | # log_calls_to( 24 | # :with_backtrace_cleaner, backtrace_cleaner: CustomCleaner 25 | # ) 26 | # end 27 | class CallLogger 28 | class << self 29 | attr_accessor :backtrace_cleaner 30 | attr_accessor :default_logger 31 | attr_accessor :catch_exception 32 | end 33 | self.default_logger = Logger.new(STDOUT) 34 | 35 | def initialize(object, method, call_args, **opts) 36 | @object = object 37 | @method = method 38 | @call_args = call_args 39 | 40 | @cleaner = opts[:backtrace_cleaner] || CallLogger.backtrace_cleaner 41 | @logger = opts[:logger] || CallLogger.default_logger 42 | @tag_proc = opts[:with] || -> {} 43 | @log_result = opts[:result] || false 44 | end 45 | 46 | attr_reader( 47 | :object, :method, :call_args, :logger, :tag_proc, :log_result, :cleaner 48 | ) 49 | 50 | def self.applier_method 51 | :log_calls_to 52 | end 53 | 54 | def call 55 | logger.info(success_message) 56 | yield.tap(&result_message) 57 | rescue exception_class => e 58 | logger.error(failure_message(e)) 59 | raise 60 | end 61 | 62 | private 63 | 64 | def success_message 65 | call_message('Called: ') 66 | end 67 | 68 | def failure_message(ex) 69 | backtrace = ["\n", ex.to_s] + ex.backtrace 70 | backtrace = cleaner.clean(backtrace) if cleaner 71 | call_message('Failed: ', backtrace.join("\n")) 72 | end 73 | 74 | def result_message 75 | lambda do |result| 76 | return unless log_result 77 | 78 | logger.info( 79 | call_message('Result: ', " => #{result.class}: #{result.inspect}") 80 | ) 81 | end 82 | end 83 | 84 | def call_message(prefix, suffix = '') 85 | "#{time}#{thread}#{id}#{custom_tag}#{prefix}\ 86 | #{klass}##{method}(#{arguments})\ 87 | #{suffix}" 88 | end 89 | 90 | def thread 91 | "[Thread=#{Thread.current.object_id}]" 92 | end 93 | 94 | def time 95 | "[Time=#{Time.now}]" 96 | end 97 | 98 | def custom_tag 99 | object.instance_exec(&tag_proc) 100 | end 101 | 102 | def klass 103 | object.class 104 | end 105 | 106 | def arguments 107 | call_args.map(&:inspect).join(', ') 108 | end 109 | 110 | def id 111 | "[id=#{object.id}]" if object.respond_to?(:id) 112 | end 113 | 114 | def exception_class 115 | CallLogger.catch_exception ? Exception : StandardError 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/advisor/advices/metriks.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'metriks' 3 | 4 | module Advisor 5 | module Advices 6 | class Metriks 7 | def initialize(object, method, _call_args, **opts) 8 | @object = object 9 | @method = method 10 | 11 | @instruments = Array(opts.fetch(:with)).uniq 12 | 13 | fail 'No instruments defined' if instruments.empty? 14 | fail 'Unknown Instrument' unless instruments.all?(&known_instrument?) 15 | end 16 | 17 | attr_reader :object, :method, :instruments 18 | 19 | INSTRUMENTS = [ 20 | :counter, :timer, :gauge, :call_meter, :result_meter 21 | ] 22 | 23 | def self.applier_method 24 | :measure 25 | end 26 | 27 | def call 28 | result = timed? ? timer.time { yield } : yield 29 | ensure 30 | instruments.each(&measure(result)) 31 | end 32 | 33 | private 34 | 35 | def measure(result) 36 | # How I wish I had currying... 37 | lambda do |instrument| 38 | is_numeric = result.is_a?(Fixnum) 39 | 40 | case instrument 41 | when :counter then counter.increment 42 | when :call_meter then call_meter.mark 43 | when :result_meter then is_numeric && result_meter.mark(result) 44 | when :gauge then is_numeric && gauge.set(result) 45 | end 46 | end 47 | end 48 | 49 | def timed? 50 | instruments.include?(:timer) 51 | end 52 | 53 | def metric_prefix 54 | "#{object.class}##{method}" 55 | end 56 | 57 | def timer 58 | ::Metriks.timer("#{metric_prefix}_#{__callee__}") 59 | end 60 | 61 | def counter 62 | ::Metriks.counter("#{metric_prefix}_#{__callee__}") 63 | end 64 | 65 | def gauge 66 | ::Metriks.gauge("#{metric_prefix}_#{__callee__}") 67 | end 68 | 69 | def call_meter 70 | ::Metriks.meter("#{metric_prefix}_#{__callee__}") 71 | end 72 | 73 | def result_meter 74 | ::Metriks.meter("#{metric_prefix}_#{__callee__}") 75 | end 76 | 77 | def known_instrument? 78 | -> (instrument) { INSTRUMENTS.include?(instrument) } 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/advisor/builtin_advisors.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module Advisor 4 | Loggable = Factory.new(Advices::CallLogger).build 5 | Measurable = Factory.new(Advices::Metriks).build 6 | end 7 | -------------------------------------------------------------------------------- /lib/advisor/factory.rb: -------------------------------------------------------------------------------- 1 | module Advisor 2 | class Factory 3 | def initialize(advice_klass) 4 | @advice_klass = advice_klass 5 | end 6 | 7 | def self.build(advice_klass) 8 | new(advice_klass).build 9 | end 10 | 11 | def build 12 | advice_klazz = advice_klass 13 | advisor_module = method(:advisor_module) 14 | 15 | Module.new do 16 | define_method(advice_klazz.applier_method) do |*methods, **args| 17 | methods_str = methods.map(&:to_s).join(', ') 18 | 19 | mod = advisor_module.call(methods, args) 20 | mod.module_eval(%(def self.inspect 21 | "#{advice_klazz}(#{methods_str})" 22 | end)) 23 | mod.module_eval(%(def self.to_s; inspect; end)) 24 | 25 | prepend mod 26 | end 27 | end 28 | end 29 | 30 | protected 31 | 32 | attr_reader :advice_klass 33 | 34 | private 35 | 36 | def advisor_module(methods, args) 37 | advice_klazz = advice_klass 38 | 39 | Module.new do 40 | methods.each do |method_name| 41 | define_method(method_name) do |*call_args, &blk| 42 | advice = advice_klazz.new(self, method_name, call_args, **args) 43 | advice.call { super(*call_args, &blk) } 44 | end 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/advisor/version.rb: -------------------------------------------------------------------------------- 1 | module Advisor 2 | VERSION = '0.6.0' 3 | end 4 | -------------------------------------------------------------------------------- /spec/advisor/advices/call_logger_spec.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | 3 | module Advisor 4 | module Advices 5 | describe CallLogger do 6 | subject(:advice) do 7 | described_class.new( 8 | object, method, args, logger: logger, with: tag, result: result 9 | ) 10 | end 11 | 12 | let(:object) { OpenStruct.new(id: 42, x: 'y') } 13 | let(:method) { 'the_meaning_of_life' } 14 | let(:args) { ['the universe', 'and everything'] } 15 | let(:logger) { instance_double(Logger) } 16 | let(:tag) { -> { "[x=#{x}]" } } 17 | let(:result) { true } 18 | 19 | let(:block) { -> { :bla } } 20 | 21 | describe '#call' do 22 | subject(:call) { advice.call(&block) } 23 | 24 | let(:log_message) do 25 | "[Time=#{Time.now}][Thread=#{Thread.current.object_id}][id=42][x=y]\ 26 | Called: OpenStruct#the_meaning_of_life(\"the universe\", \"and everything\")" 27 | end 28 | 29 | let(:result_log_message) do 30 | "[Time=#{Time.now}][Thread=#{Thread.current.object_id}][id=42][x=y]\ 31 | Result: OpenStruct#the_meaning_of_life(\"the universe\", \"and everything\") \ 32 | => Symbol: :bla" 33 | end 34 | 35 | before do 36 | allow(Time).to receive(:now).and_return(Time.now) 37 | allow(logger).to receive(:info) 38 | end 39 | 40 | it { is_expected.to eq(:bla) } 41 | 42 | it do 43 | expect(logger).to receive(:info).with(log_message) 44 | 45 | call 46 | end 47 | 48 | it do 49 | expect(logger).to receive(:info).with(result_log_message) 50 | 51 | call 52 | end 53 | 54 | context 'when yielding the block raises an error' do 55 | let(:block) { -> () { fail 'deu ruim!' } } 56 | 57 | let(:log_message) do 58 | /\[Time=#{Regexp.quote(Time.now.to_s)}\]\ 59 | \[Thread=#{Thread.current.object_id}\]\ 60 | \[id=42\]\[x=y\]Failed: OpenStruct#the_meaning_of_life\(\"the universe\", \"and\ 61 | everything\"\).*/ 62 | end 63 | 64 | let(:catch_exception) { false } 65 | 66 | before do 67 | allow(logger).to receive(:error) 68 | allow(CallLogger).to receive(:catch_exception) 69 | .and_return(catch_exception) 70 | end 71 | 72 | it { expect { call }.to raise_error(StandardError, 'deu ruim!') } 73 | 74 | it do 75 | expect(logger).to receive(:error).with(log_message) 76 | expect { call }.to raise_error 77 | end 78 | 79 | it do 80 | expect(logger).to receive(:error).with(log_message) 81 | expect { call }.to raise_error 82 | end 83 | 84 | context 'when the error is not a StandardError' do 85 | let(:block) { -> { fail Exception, 'deu muito ruim!' } } 86 | 87 | it do 88 | expect(logger).not_to receive(:error).with(log_message) 89 | expect { call }.to raise_error(Exception, 'deu muito ruim!') 90 | end 91 | 92 | context 'when catching exceptions' do 93 | let(:catch_exception) { true } 94 | 95 | it do 96 | expect(logger).to receive(:error).with(log_message) 97 | expect { call }.to raise_error(Exception, 'deu muito ruim!') 98 | end 99 | 100 | it do 101 | expect(logger).to receive(:error).with(log_message) 102 | expect { call }.to raise_error(Exception, 'deu muito ruim!') 103 | end 104 | end 105 | end 106 | end 107 | 108 | context 'when no custom tag is provided' do 109 | let(:tag) {} 110 | let(:log_without_custom_tag) { log_message.gsub('[x=y]', '') } 111 | 112 | it do 113 | expect(logger).to receive(:info).with(log_without_custom_tag) 114 | 115 | call 116 | end 117 | end 118 | 119 | context 'when it is not provided to log the result' do 120 | let(:result) { false } 121 | 122 | it do 123 | expect(logger).not_to receive(:info).with(result_log_message) 124 | 125 | call 126 | end 127 | end 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/advisor/advices/metriks_spec.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | 3 | module Advisor 4 | module Advices 5 | describe Metriks do 6 | subject(:advice) do 7 | described_class.new( 8 | object, 9 | method, 10 | args, 11 | logger: logger, 12 | with: instruments 13 | ) 14 | end 15 | 16 | let(:object) { OpenStruct.new(when: '2015-12-18') } 17 | let(:method) { 'the_force_awakens' } 18 | let(:args) { ['vai ser zika', 'demais'] } 19 | let(:logger) { instance_double(Logger, warn: nil) } 20 | 21 | let(:block) { -> { 42 } } 22 | 23 | context 'when no instruments are specified' do 24 | let(:instruments) { [] } 25 | 26 | it { expect { call }.to raise_error } 27 | end 28 | 29 | describe '.applier_method' do 30 | subject { Metriks.applier_method } 31 | it { is_expected.to eq(:measure) } 32 | end 33 | 34 | describe '#call' do 35 | subject(:call) { advice.call(&block) } 36 | 37 | let(:instruments) { %i(counter result_meter call_meter gauge) } 38 | 39 | shared_examples_for 'instruments && measuring' do 40 | it('returns the block call value') { is_expected.to eq(42) } 41 | 42 | it 'instantiates a counter with the right metric name' do 43 | expect(::Metriks).to receive(:counter) 44 | .with('OpenStruct#the_force_awakens_counter') 45 | .and_call_original 46 | 47 | subject 48 | end 49 | 50 | it 'instantiates gauge with the right metric name' do 51 | expect(::Metriks).to receive(:gauge) 52 | .with('OpenStruct#the_force_awakens_gauge') 53 | .and_call_original 54 | 55 | subject 56 | end 57 | 58 | it 'instantiates a method call and a meter with the right metric names' do 59 | expect(::Metriks).to receive(:meter) 60 | .with('OpenStruct#the_force_awakens_call_meter') 61 | .and_call_original 62 | 63 | expect(::Metriks).to receive(:meter) 64 | .with('OpenStruct#the_force_awakens_result_meter') 65 | .and_call_original 66 | 67 | subject 68 | end 69 | 70 | let(:result_meter) { instance_double(::Metriks::Meter) } 71 | let(:call_meter) { instance_double(::Metriks::Meter) } 72 | 73 | it 'increments a method call counter' do 74 | expect(::Metriks).to receive_message_chain( 75 | :counter, :increment 76 | ) 77 | 78 | subject 79 | end 80 | 81 | it 'marks a method call and a block result value meter' do 82 | expect(::Metriks).to receive(:meter) 83 | .with('OpenStruct#the_force_awakens_result_meter') 84 | .and_return(result_meter) 85 | 86 | expect(result_meter).to receive(:mark) 87 | .with(42) 88 | 89 | expect(::Metriks).to receive(:meter) 90 | .with('OpenStruct#the_force_awakens_call_meter') 91 | .and_return(call_meter) 92 | 93 | expect(call_meter).to receive(:mark) 94 | 95 | subject 96 | end 97 | 98 | it 'sets the block result value gauge' do 99 | expect(::Metriks).to receive_message_chain( 100 | :gauge, :set 101 | ).with(42) 102 | 103 | subject 104 | end 105 | 106 | context 'when the block return value is not numeric' do 107 | let(:block) { -> { :war_in_the_stars } } 108 | 109 | it 'does not instantiate a block return value meter' do 110 | expect(::Metriks).to receive(:meter) 111 | .with('OpenStruct#the_force_awakens_call_meter') 112 | .and_return(call_meter) 113 | expect(call_meter).to receive(:mark) 114 | 115 | expect(::Metriks).not_to receive(:meter) 116 | .with('OpenStruct#the_force_awakens_result_meter') 117 | 118 | subject 119 | end 120 | 121 | it 'does not instantiate a block return value gauge' do 122 | expect(::Metriks).not_to receive(:gauge) 123 | 124 | subject 125 | end 126 | 127 | it 'instantiates a method call meter and marks it' do 128 | expect(::Metriks).to receive(:counter) 129 | .and_call_original 130 | 131 | subject 132 | end 133 | end 134 | end 135 | 136 | it_behaves_like 'instruments && measuring' 137 | 138 | context 'when using a timer' do 139 | let(:instruments) { %i(counter result_meter call_meter gauge timer) } 140 | 141 | let(:timer) { ::Metriks.timer('a-timer') } 142 | 143 | before do 144 | allow(::Metriks).to receive(:timer) 145 | .and_return(timer) 146 | end 147 | 148 | it_behaves_like 'instruments && measuring' 149 | 150 | it 'finds the right timer metric' do 151 | expect(::Metriks).to receive(:timer) 152 | .with('OpenStruct#the_force_awakens_timer') 153 | .and_call_original 154 | 155 | call 156 | end 157 | 158 | it 'times the method call' do 159 | expect(timer).to receive(:time) 160 | .and_call_original 161 | 162 | call 163 | end 164 | end 165 | 166 | context 'when there are duplicate instruments' do 167 | let(:instruments) do 168 | %i(counter result_meter call_meter counter gauge gauge) 169 | end 170 | 171 | it_behaves_like 'instruments && measuring' 172 | end 173 | 174 | context 'when the block throws an exception' do 175 | let(:block) { -> { fail 'i be error' } } 176 | 177 | let(:instruments) { %i(gauge timer counter) } 178 | 179 | it 'raises the error raised when yielding the block' do 180 | expect { call }.to raise_error(RuntimeError, 'i be error') 181 | end 182 | 183 | it 'times the method call' do 184 | expect(::Metriks).to receive(:timer) 185 | .with('OpenStruct#the_force_awakens_timer') 186 | .and_call_original 187 | 188 | expect { call }.to raise_error 189 | end 190 | 191 | it 'does measure the execution with a nil result' do 192 | expect(advice).to receive(:measure) 193 | .with(nil) 194 | .once 195 | 196 | expect { call }.to raise_error 197 | end 198 | 199 | it 'does not measure with a gauge' do 200 | expect(::Metriks).not_to receive(:gauge) 201 | 202 | expect { call }.to raise_error 203 | end 204 | 205 | it 'does increment the counter' do 206 | expect(::Metriks).to receive(:counter) 207 | .with('OpenStruct#the_force_awakens_counter') 208 | .and_call_original 209 | 210 | expect { call }.to raise_error 211 | end 212 | end 213 | end 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /spec/advisor/builtin_advisors_spec.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module Advisor 4 | describe Factory do 5 | subject(:loggable) do 6 | Advices::CallLogger.default_logger = default_logger 7 | 8 | Class.new do 9 | extend Loggable 10 | log_calls_to :inc 11 | 12 | def inc(number) 13 | number + 1 14 | end 15 | 16 | def foo(*) 17 | end 18 | end 19 | end 20 | 21 | let(:loggable_instance) { loggable.new } 22 | let(:default_logger) { instance_double(Logger, info: nil, warn: nil) } 23 | let(:call_logger) do 24 | Advices::CallLogger.new( 25 | loggable_instance, :inc, [1], logger: default_logger 26 | ) 27 | end 28 | 29 | before do 30 | allow(Advices::CallLogger).to receive(:new) 31 | .and_return(call_logger) 32 | end 33 | 34 | it 'instantiates a call logger when calling the advised method' do 35 | expect(Advices::CallLogger) 36 | .to receive(:new) 37 | .with(loggable_instance, :inc, [1], {}) 38 | .and_call_original 39 | 40 | loggable_instance.inc(1) 41 | end 42 | 43 | it 'uses the call_logger to log the method call' do 44 | expect(call_logger) 45 | .to receive(:call) 46 | .and_call_original 47 | 48 | loggable_instance.inc(1) 49 | end 50 | 51 | it 'does not change the return value' do 52 | expect(loggable_instance.inc(1)).to eq(2) 53 | end 54 | 55 | it 'does not log when calling a non-advised method' do 56 | expect(Advices::CallLogger).to_not receive(:new) 57 | 58 | loggable_instance.foo 59 | end 60 | 61 | describe '#log_calls_to' do 62 | subject(:log_calls_to) { loggable.send(:log_calls_to, :foo, :bar) } 63 | 64 | it 'prepends an anonymous module in the ancestor chain' do 65 | expect(loggable).to receive(:prepend) 66 | .and_call_original 67 | 68 | expect { log_calls_to }.to change { loggable.ancestors.count }.by(1) 69 | end 70 | 71 | context 'when redefining an advised method' do 72 | let(:child_class) do 73 | Class.new(loggable) do 74 | def inc(number) 75 | number + 2 76 | end 77 | end 78 | end 79 | 80 | let(:child_class_instance) { child_class.new } 81 | 82 | it 'the advice is overridden' do 83 | log_calls_to 84 | 85 | expect(Advices::CallLogger).not_to receive(:new) 86 | expect(child_class_instance.inc(1)).to eq(3) 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/advisor/factory_spec.rb: -------------------------------------------------------------------------------- 1 | describe Advisor::Factory do 2 | subject(:factory) { described_class.new(advice_klass) } 3 | 4 | let(:advice_klass) do 5 | Struct.new(:obj, :method, :call_args, :args) do 6 | define_method(:call) { 'overridden!' } 7 | define_singleton_method(:applier_method) { 'apply_advice_to' } 8 | end 9 | end 10 | let(:advice_instance) do 11 | advice_klass.new(advised_instance, :apply_advice_to, [], arg1: 1, arg2: 2) 12 | end 13 | 14 | let(:advised_klass) do 15 | advisor = build 16 | 17 | Struct.new(:advised_method) do 18 | extend advisor 19 | 20 | apply_advice_to :advised_method, arg1: 1, arg2: 2 21 | end 22 | end 23 | let(:advised_instance) { advised_klass.new(33) } 24 | 25 | before do 26 | allow(advised_klass).to receive(:new) 27 | .and_return(advised_instance) 28 | 29 | allow(advice_klass).to receive(:new) 30 | .and_return(advice_instance) 31 | end 32 | 33 | describe '#build' do 34 | subject(:build) { factory.build } 35 | 36 | it { is_expected.to be_kind_of(Module) } 37 | 38 | describe 'when applying the advice to methods' do 39 | subject(:invoke_advised_method) { advised_instance.advised_method } 40 | 41 | it do 42 | expect(advice_klass).to receive(:new) 43 | .with(advised_instance, :advised_method, [], arg1: 1, arg2: 2) 44 | 45 | invoke_advised_method 46 | end 47 | 48 | it do 49 | expect(advice_instance).to receive(:call) 50 | 51 | invoke_advised_method 52 | end 53 | it { is_expected.to eq('overridden!') } 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'advisor' 2 | 3 | RSpec.configure do |config| 4 | config.order = 'random' 5 | end 6 | --------------------------------------------------------------------------------