├── .github └── CODEOWNERS ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── timeasure.rb └── timeasure │ ├── class_methods.rb │ ├── configuration.rb │ ├── measurement.rb │ ├── profiling │ ├── manager.rb │ ├── reported_method.rb │ └── reported_methods_handler.rb │ └── version.rb ├── spec ├── spec_helper.rb ├── timeasure │ └── profiling │ │ ├── manager_spec.rb │ │ └── method_reporting_spec.rb └── timeasure_acceptence_spec.rb └── timeasure.gemspec /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | /.github/ @Riskified/dev-sre-sg @Riskified/biztech-security-secops-sg 2 | /.infrastructure/ @Riskified/dev-sre-sg @Riskified/biztech-security-secops-sg 3 | /.infrastructure/terraform/aws/accounts/**/dynamodb.tf @Riskified/dev-data-data-apps-sg 4 | /.infrastructure/terraform/aws/accounts/**/elasticache.tf @Riskified/dev-data-data-apps-sg 5 | /.metadata/REPOSITORYOWNERS @Riskified/biztech-security-secops-sg 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | .idea/* 24 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.4.2 3 | 4 | Metrics/LineLength: 5 | Max: 120 6 | 7 | Documentation: 8 | Enabled: false 9 | 10 | Metrics/BlockLength: 11 | ExcludedMethods: ['describe', 'context', 'before'] -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.2.1] - 2018-12-12 10 | ### Changed 11 | - Internal implementation of the Measurement class to avoid unneeded re-calculation of constant values. 12 | 13 | 14 | ## [0.2.0] - 2018-03-12 15 | ### Added 16 | - New class macros for tracking private methods in both inline and scoped visibility declaration. 17 | 18 | ### Changed 19 | - Start using `Process.clock_gettime(Process::CLOCK_MONOTONIC)` instead of `Time.now` for time tracking 20 | - Bumped up required Ruby version from 2.0 to 2.1 21 | 22 | ## [0.1.1] - 2018-02-24 23 | ### Added 24 | - Specs for describing the proper way to track private methods. 25 | 26 | ### Fixed 27 | - Minor performance issue in class macros 28 | 29 | ## [0.1.0] - 2018-02-24 30 | ### Added 31 | - Timeasure main code 32 | - Timeasure profiler 33 | 34 | [0.2.0]: https://github.com/Riskified/timeasure/compare/v0.1.1...v0.2.0 35 | [0.1.1]: https://github.com/Riskified/timeasure/compare/v0.1.0...v0.1.1 36 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in timeasure.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Eliav Lavi 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/timeasure.svg)](https://badge.fury.io/rb/timeasure) 2 | [![Maintainability](https://api.codeclimate.com/v1/badges/0ceacd5b50b0cd45fb8f/maintainability)](https://codeclimate.com/github/Riskified/timeasure/maintainability) 3 | 4 | # Timeasure 5 | 6 | **What Is It?** 7 | 8 | Timeasure is a transparent method-level wrapper for profiling purposes. See a live example [right here!](https://timeasure-demo.herokuapp.com/) 9 | 10 | Timeasure is a Ruby gem that allows measuring the runtime of methods in production environments 11 | without having to alter the code of the methods themselves. 12 | 13 | Timeasure allows you to declare tracked methods to be measured transparently upon each call. 14 | Measured calls are then reported to Timeasure's Profiler, which aggregates the measurements on the method level. 15 | This part is configurable and if you wish you can report measurements to another profiler of your choice. 16 | 17 | **Why Use It?** 18 | 19 | Timeasure was created in order to serve as an easy-to-use, self-contained framework for method-level profiling 20 | that is safe to use in production. Testing runtime in non-production environments is helpful, but there is 21 | great value to the knowledge gained by measuring what really goes on at real time. 22 | 23 | **What To Do With the Data?** 24 | 25 | The imagined usage of measured methods timing is to aggregate it along a certain transaction and report it to a live 26 | BI service such as [NewRelic Insights](https://newrelic.com/insights) or [Keen.io](https://keen.io/); 27 | however, different usages might prove helpful as well, such as writing the data to a database or a file. 28 | 29 | **General Notes** 30 | 31 | Timeasure uses minimal intervention in the Ruby Object Model for tracked modules and classes. 32 | It integrates well within Rails and non-Rails apps. 33 | 34 | Timeasure is inspired by [Metaprogramming Ruby 2](https://pragprog.com/book/ppmetr2/metaprogramming-ruby-2) 35 | by [Paolo Perrotta](https://twitter.com/nusco) 36 | and by [this](https://hashrocket.com/blog/posts/module-prepend-a-super-story) blog post by Hashrocket. 37 | 38 | Timeasure is developed and maintained by [Eliav Lavi](http://www.eliavlavi.com) & [Riskified](https://www.riskified.com/). 39 | 40 | ## Requirements 41 | 42 | Ruby 2.1 or a later version is mandatory. (Timeasure uses `Module#prepend` introduced in Ruby 2.0 and `Process::CLOCK_MONOTONIC` introduced in Ruby 2.1.) 43 | 44 | ## Installation 45 | 46 | Add this line to your application's Gemfile: 47 | 48 | gem 'timeasure' 49 | 50 | And then execute: 51 | 52 | $ bundle 53 | 54 | Or install it yourself as: 55 | 56 | $ gem install timeasure 57 | 58 | ## Usage 59 | #### 1. Include Timeasure in Modules and Classes 60 | Simply include the Timeasure module in any class or module and declare the desired methods to track: 61 | 62 | ```ruby 63 | class Foo 64 | include Timeasure 65 | tracked_class_methods :bar 66 | tracked_instance_methods :baz, :qux 67 | 68 | def self.bar 69 | # some class-level stuff that can benefit from measuring runtime... 70 | end 71 | 72 | def baz 73 | # some instance-level stuff that can benefit from measuring runtime... 74 | end 75 | 76 | def qux 77 | # some other instance-level stuff that can benefit from measuring runtime... 78 | end 79 | end 80 | ``` 81 | **An Important Note Regarding Private Methods** 82 | 83 | If you need to track any private methods - either class methods or instance methods - use the designated class macros for that: 84 | 85 | ```ruby 86 | class Foo 87 | include Timeasure 88 | tracked_class_methods :a_class_method_that_calls_private_methods 89 | tracked_private_class_methods :a_scoped_private_class_method, :an_inline_private_class_method 90 | 91 | class << self 92 | def a_class_method_that_calls_private_methods 93 | a_scoped_private_class_method 94 | an_inline_private_class_method 95 | end 96 | 97 | def a_scoped_private_class_method 98 | # some private class-level stuff that can benefit from measuring runtime... 99 | end 100 | end 101 | 102 | def self.an_inline_private_class_method 103 | # some other private class-level stuff that can benefit from measuring runtime... 104 | end 105 | end 106 | ``` 107 | 108 | And the instance-level equivalent: 109 | 110 | ```ruby 111 | class Foo 112 | include Timeasure 113 | tracked_instance_methods :a_instance_method_that_calls_private_methods 114 | tracked_private_instance_methods :a_scoped_private_instance_method, :an_inline_private_instance_method 115 | 116 | class << self 117 | def a_instance_method_that_calls_private_methods 118 | a_scoped_private_instance_method 119 | an_inline_private_instance_method 120 | end 121 | 122 | def a_scoped_private_instance_method 123 | # some private instance-level stuff that can benefit from measuring runtime... 124 | end 125 | end 126 | 127 | def self.an_inline_private_instance_method 128 | # some other private instance-level stuff that can benefit from measuring runtime... 129 | end 130 | end 131 | ``` 132 | 133 | **ATTENTION!** 134 | 135 | **Declaring the tracking of private methods with `tracked_class_methods` or `tracked_instance_methods` will end up in `NoMethodError` upon calling their triggering method!** 136 | 137 | Also, tracking your public methods with `tracked_private_class_methods` or `tracked_private_instance_methods` will make your class' interface inaccessible. 138 | The reason for these two is that since Timeasure is declared at the top of the class, 139 | it cannot know in advance which methods will be declared as private, so you need to specify this explicitly. 140 | 141 | As a side note, it could be claimed that as a rule of thumb, if you find yourself measuring private methods, 142 | this might be a good idea to invest in refactoring this area of code and [Extract Class](https://refactoring.guru/extract-class). 143 | However, this is not always possible, of course, especially when working on legacy code. 144 | Hence, this feature of Timeasure should be considered as somewhat of a last resort and be handled with care. 145 | 146 | #### 2. Define the Boundaries of the Tracked Transaction 147 | **Preparing for Method Tracking** 148 | 149 | The user is responsible for managing the final reporting and the clean-up of the aggregated data after each transation. 150 | It is recommended to prepare the profiler at the beginning of a transaction in which tracked methods exist with 151 | 152 | ```ruby 153 | Timeasure::Profiling::Manager.prepare 154 | ``` 155 | and to re-prepare it again at the end of it in order to ensure a "clean slate" - 156 | after you have handled the aggregated data in some way. 157 | 158 | **Getting Hold of the Data** 159 | 160 | In order to get hold of the reported methods data, use 161 | ```ruby 162 | Timeasure::Profiling::Manager.export 163 | ```` 164 | This will return an array of `ReportedMethod`s. Each `ReportedMethod` object holds the aggregated timing data per 165 | each tracked method call. This means that no matter how many times you call a tracked method, Timeasure's Profiler will 166 | still hold a single `ReportedMethod` object to represent it. 167 | 168 | `ReportedMethod` allows reading the following attributes: 169 | * `klass_name`: Name of the class in which the tracked method resides. 170 | * `method_name`: Name of the tracked method. 171 | * `segment`: See [Segmented Method Tracking](#segmented-method-tracking) below. 172 | * `metadata`: See [Carrying Metadata](#carrying-metadata) below. 173 | * `method_path`: `klass_name` and `method_name` concatenated. 174 | * `full_path`: Same as `method_path` unless segmentation is declared, 175 | in which case the segment will be concatenated to the string as well. See [Segmented Method Tracking](#segmented-method-tracking) below. 176 | * `runtime_sum`: The aggregated time it took the reported method in question to run across all calls. 177 | * `call_count`: The times the reported method in question was called across all calls. 178 | 179 | 180 | ## Advanced Usage 181 | #### Segmented Method Tracking 182 | Timeasure was designed to separate regular code from its time measurement declaration. 183 | This is achieved by Timeasure's class macros `tracked_class_methods` and `tracked_instance_methods`. 184 | Sometimes, however, the need for additional data might arise. Imagine this method: 185 | 186 | ```ruby 187 | class Foo 188 | def bar(baz) 189 | # some stuff that can benefit from measuring runtime 190 | # yet its runtime is also highly affected by the value of baz... 191 | end 192 | end 193 | ``` 194 | 195 | We've seen how Timeasure makes it easy to measure the `bar` method. 196 | However, if we wish to segment each call by the value of `baz`, 197 | we may use Timeasure's direct interface and send this value as a **segment**: 198 | 199 | ```ruby 200 | class Foo 201 | def bar(baz) 202 | Timeasure.measure(klass_name: 'Foo', method_name: 'bar', segment: { baz: baz }) do 203 | # the code to be measured 204 | end 205 | end 206 | end 207 | ``` 208 | 209 | For such calls, Timeasure's Profiler will aggregate the data in `ReportedMethod` objects grouped by 210 | class, method and segment. 211 | 212 | This approach obviously violates Timeasure's idea of separating code and measurement-declaration, 213 | but it allows for much more detailed investigations, if needed. 214 | This will result in different `ReportedMethod` object in Timeasure's Profiler for 215 | each combination of class, method and segment. Accordingly, such `ReportedMethod` object will include 216 | these three elements, concatenated, as the value for `ReportedMethod#full_path`. 217 | 218 | #### Carrying Metadata 219 | This feature was developed in order to complement the segmented method tracking. 220 | 221 | Sometimes carrying data with measurement that does not define a segment might be needed. 222 | For example, assuming we save all our `ReportedMethod`s to some table called `reported_methods`, 223 | we might want to supply a custom table name for specific measurements. 224 | This might be achieved by using `metadata`: 225 | 226 | ```ruby 227 | class Foo 228 | def bar 229 | Timeasure.measure(klass_name: 'Foo', method_name: 'bar', metadata: { table_name: 'my_custom_table' }) do 230 | # the code to be measured 231 | end 232 | end 233 | end 234 | ``` 235 | 236 | Unlike Segments, Timeasure only carries the Metadata onwards. 237 | It is up to the user to make use of this data, probably after calling `Timeasure::Profiling::Manager.export`. 238 | 239 | ## Notes 240 | #### Compatibility with RSpec 241 | 242 | If you run your test suite with Timeasure installed and modules, classes and methods tracked and all works fine - hurray! 243 | However, due to the mechanics of Timeasure - namely, its usage of prepended modules - there exist a problem with 244 | **stubbing** Timeasure-tracked method (RSpec does not support stubbing methods that appear in a prepended module). 245 | To be accurate, that means that if you are tracking method `#foo`, you can not 246 | declare something like `allow(bar).to receive(:foo).and_return(bar)`. Your specs will refuse to run in this case. 247 | To solve that problem you can configure Timeasure's `enable_timeasure_proc` **not** to run under certain conditions. 248 | 249 | If you are on Rails, add the following as a Rails initializer: 250 | 251 | ```ruby 252 | require 'timeasure' 253 | 254 | Timeasure.configure do |configuration| 255 | configuration.enable_timeasure_proc = lambda { !Rails.env.test? } 256 | end 257 | ``` 258 | 259 | Timeasure will not come into action if the expression in the block evaluates to `false`. 260 | By default this block evaluates to `true`. 261 | 262 | In case you are loading files manually (probably not on Rails), you can add this to *spec_helper.rb*: 263 | 264 | ```ruby 265 | RSpec.configure do |config| 266 | config.before(:suite) do 267 | Timeasure.configure do |configuration| 268 | configuration.enable_timeasure_proc = lambda { false } 269 | end 270 | end 271 | end 272 | ``` 273 | 274 | 275 | ## Feature Requests 276 | 277 | Timeasure is open for changes and requests! 278 | If you have an idea, a question or some need, feel free to contact me here or at eliavlavi@gmail.com. 279 | 280 | ## Contributing 281 | 282 | 1. Fork it ( https://github.com/riskified/timeasure/fork ) 283 | 2. Create your feature branch (`git checkout -b my-new-feature`) 284 | 3. Commit your changes (`git commit -am 'Add some feature'`) 285 | 4. Push to the branch (`git push origin my-new-feature`) 286 | 5. Create a new Pull Request 287 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | -------------------------------------------------------------------------------- /lib/timeasure.rb: -------------------------------------------------------------------------------- 1 | require_relative 'timeasure/version' 2 | require_relative 'timeasure/configuration' 3 | require_relative 'timeasure/class_methods' 4 | require_relative 'timeasure/measurement' 5 | require_relative 'timeasure/profiling/manager' 6 | 7 | module Timeasure 8 | class << self 9 | def configure 10 | yield(configuration) 11 | end 12 | 13 | def configuration 14 | @configuration ||= Configuration.new 15 | end 16 | 17 | def included(base_class) 18 | base_class.extend ClassMethods 19 | 20 | instance_interceptor = const_set(instance_interceptor_name_for(base_class), interceptor_module_for(base_class)) 21 | class_interceptor = const_set(class_interceptor_name_for(base_class), interceptor_module_for(base_class)) 22 | 23 | return unless timeasure_enabled? 24 | 25 | base_class.prepend instance_interceptor 26 | base_class.singleton_class.prepend class_interceptor 27 | end 28 | 29 | def measure(klass_name: nil, method_name: nil, segment: nil, metadata: nil) 30 | t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) 31 | block_return_value = yield if block_given? 32 | t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC) 33 | 34 | begin 35 | measurement = Timeasure::Measurement.new(klass_name: klass_name.to_s, method_name: method_name.to_s, 36 | segment: segment, metadata: metadata, t0: t0, t1: t1) 37 | Timeasure.configuration.post_measuring_proc.call(measurement) 38 | rescue => e 39 | Timeasure.configuration.rescue_proc.call(e, klass_name) 40 | end 41 | 42 | block_return_value 43 | end 44 | 45 | private 46 | 47 | def instance_interceptor_name_for(base_class) 48 | "#{base_class.timeasure_name}InstanceInterceptor" 49 | end 50 | 51 | def class_interceptor_name_for(base_class) 52 | "#{base_class.timeasure_name}ClassInterceptor" 53 | end 54 | 55 | def interceptor_module_for(base_class) 56 | Module.new do 57 | @klass_name = base_class 58 | 59 | def self.klass_name 60 | @klass_name 61 | end 62 | end 63 | end 64 | 65 | def timeasure_enabled? 66 | configuration.enable_timeasure_proc.call 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/timeasure/class_methods.rb: -------------------------------------------------------------------------------- 1 | module Timeasure 2 | module ClassMethods 3 | def tracked_instance_methods(*method_names) 4 | method_names.each do |method_name| 5 | add_method_to_interceptor(instance_interceptor, method_name) 6 | end 7 | end 8 | 9 | def tracked_class_methods(*method_names) 10 | method_names.each do |method_name| 11 | add_method_to_interceptor(class_interceptor, method_name) 12 | end 13 | end 14 | 15 | def tracked_private_instance_methods(*method_names) 16 | tracked_instance_methods(*method_names) 17 | method_names.each { |method_name| privatize_interceptor_method(instance_interceptor, method_name) } 18 | end 19 | 20 | def tracked_private_class_methods(*method_names) 21 | tracked_class_methods(*method_names) 22 | method_names.each { |method_name| privatize_interceptor_method(class_interceptor, method_name) } 23 | end 24 | 25 | def timeasure_name 26 | name.gsub('::', '_') 27 | end 28 | 29 | private 30 | 31 | def add_method_to_interceptor(interceptor, method_name) 32 | interceptor.class_eval do 33 | define_method method_name do |*args, &block| 34 | Timeasure.measure(klass_name: interceptor.klass_name.to_s, method_name: method_name.to_s) do 35 | super(*args, &block) 36 | end 37 | end 38 | end 39 | end 40 | 41 | def privatize_interceptor_method(interceptor, method_name) 42 | interceptor.class_eval { private method_name } 43 | end 44 | 45 | def instance_interceptor 46 | const_get("#{timeasure_name}InstanceInterceptor") 47 | end 48 | 49 | def class_interceptor 50 | const_get("#{timeasure_name}ClassInterceptor") 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/timeasure/configuration.rb: -------------------------------------------------------------------------------- 1 | module Timeasure 2 | class Configuration 3 | attr_accessor :post_measuring_proc, :rescue_proc, :enable_timeasure_proc, 4 | :reported_methods_handler_set_proc, :reported_methods_handler_get_proc 5 | 6 | def initialize 7 | @post_measuring_proc = lambda do |measurement| 8 | # Enables the configuration of what to do with each method runtime measurement. 9 | # By default it reports to Timeasure's Profiling Manager. 10 | 11 | Timeasure::Profiling::Manager.report(measurement) 12 | end 13 | 14 | @rescue_proc = lambda do |e, klass| 15 | # Enabled the configuration of post_measuring_proc rescue. 16 | end 17 | 18 | @enable_timeasure_proc = lambda do 19 | # Enables toggling Timeasure's activation (e.g. for disabling Timeasure for RSpec). 20 | 21 | true 22 | end 23 | 24 | @reported_methods_handler_set_proc = lambda do |reported_methods_handler| 25 | # Enables configuring where to store the ReportedMethodsHandler instance. 26 | # This proc will be called by Timeasure::Profiling::Manager.prepare. 27 | # By default it stores the handler as a class instance variable (in Timeasure::Profiling::Manager) 28 | 29 | @reported_methods_handler = reported_methods_handler 30 | end 31 | 32 | @reported_methods_handler_get_proc = lambda do 33 | # Enables configuring where to fetch the ReportedMethodsHandler instance. 34 | # This proc will be called by Timeasure::Profiling::Manager.report and Timeasure::Profiling::Manager.export. 35 | # By default it fetches the handler from the class instance variable 36 | # (see @reported_methods_handler_set_proc). 37 | 38 | @reported_methods_handler 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/timeasure/measurement.rb: -------------------------------------------------------------------------------- 1 | module Timeasure 2 | class Measurement 3 | attr_reader :klass_name, :method_name, :segment, :metadata, :t0, :t1 4 | 5 | def initialize(klass_name:, method_name:, t0:, t1:, segment: nil, metadata: nil) 6 | @klass_name = klass_name 7 | @method_name = method_name 8 | @t0 = t0 9 | @t1 = t1 10 | @segment = segment 11 | @metadata = metadata 12 | end 13 | 14 | def runtime_in_milliseconds 15 | @runtime_in_milliseconds ||= (@t1 - @t0) * 1000 16 | end 17 | 18 | def full_path 19 | @full_path ||= @segment.nil? ? method_path : "#{method_path}:#{@segment}" 20 | end 21 | 22 | def method_path 23 | @method_path ||= "#{@klass_name}##{@method_name}" 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/timeasure/profiling/manager.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require_relative 'reported_methods_handler' 3 | require_relative 'reported_method' 4 | 5 | module Timeasure 6 | module Profiling 7 | class Manager 8 | class << self 9 | def prepare 10 | Timeasure.configuration.reported_methods_handler_set_proc.call(ReportedMethodsHandler.new) 11 | end 12 | 13 | def report(measurement) 14 | handler = reported_methods_handler 15 | handler.nil? ? warn_unprepared_handler : handler.report(measurement) 16 | end 17 | 18 | def export 19 | handler = reported_methods_handler 20 | handler.nil? ? warn_unprepared_handler : handler.export 21 | end 22 | 23 | private 24 | 25 | def reported_methods_handler 26 | Timeasure.configuration.reported_methods_handler_get_proc.call 27 | end 28 | 29 | def warn_unprepared_handler 30 | logger.warn("#{self} is not prepared. Call Timeasure::Profiling::Manager.prepare before trying to report measurements or export reported methods.") 31 | end 32 | 33 | def logger 34 | @logger ||= Logger.new(STDOUT) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/timeasure/profiling/reported_method.rb: -------------------------------------------------------------------------------- 1 | module Timeasure 2 | module Profiling 3 | class ReportedMethod 4 | attr_reader :klass_name, :method_name, :segment, :metadata, :full_path, :method_path, :runtime_sum, :call_count 5 | 6 | def initialize(measurement) 7 | @klass_name = measurement.klass_name 8 | @method_name = measurement.method_name 9 | @segment = measurement.segment 10 | @metadata = measurement.metadata 11 | @full_path = measurement.full_path 12 | @method_path = measurement.method_path 13 | 14 | @runtime_sum = 0 15 | @call_count = 0 16 | end 17 | 18 | def increment_runtime_sum(runtime) 19 | @runtime_sum += runtime 20 | end 21 | 22 | def increment_call_count 23 | @call_count += 1 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/timeasure/profiling/reported_methods_handler.rb: -------------------------------------------------------------------------------- 1 | module Timeasure 2 | module Profiling 3 | class ReportedMethodsHandler 4 | def initialize 5 | @reported_methods = {} 6 | end 7 | 8 | def report(measurement) 9 | initialize_path_for(measurement) if path_uninitialized_for(measurement) 10 | 11 | @reported_methods[measurement.full_path].increment_runtime_sum(measurement.runtime_in_milliseconds) 12 | @reported_methods[measurement.full_path].increment_call_count 13 | end 14 | 15 | def export 16 | @reported_methods.values 17 | end 18 | 19 | private 20 | 21 | def path_uninitialized_for(measurement) 22 | @reported_methods[measurement.full_path].nil? 23 | end 24 | 25 | def initialize_path_for(measurement) 26 | @reported_methods[measurement.full_path] = ReportedMethod.new(measurement) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/timeasure/version.rb: -------------------------------------------------------------------------------- 1 | module Timeasure 2 | VERSION = '0.2.1' 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 16 | RSpec.configure do |config| 17 | # rspec-expectations config goes here. You can use an alternate 18 | # assertion/expectation library such as wrong or the stdlib/minitest 19 | # assertions if you prefer. 20 | config.expect_with :rspec do |expectations| 21 | # This option will default to `true` in RSpec 4. It makes the `description` 22 | # and `failure_message` of custom matchers include text for helper methods 23 | # defined using `chain`, e.g.: 24 | # be_bigger_than(2).and_smaller_than(4).description 25 | # # => "be bigger than 2 and smaller than 4" 26 | # ...rather than: 27 | # # => "be bigger than 2" 28 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 29 | end 30 | 31 | # rspec-mocks config goes here. You can use an alternate test double 32 | # library (such as bogus or mocha) by changing the `mock_with` option here. 33 | config.mock_with :rspec do |mocks| 34 | # Prevents you from mocking or stubbing a method that does not exist on 35 | # a real object. This is generally recommended, and will default to 36 | # `true` in RSpec 4. 37 | mocks.verify_partial_doubles = true 38 | end 39 | 40 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 41 | # have no way to turn it off -- the option exists only for backwards 42 | # compatibility in RSpec 3). It causes shared context metadata to be 43 | # inherited by the metadata hash of host groups and examples, rather than 44 | # triggering implicit auto-inclusion in groups with matching metadata. 45 | config.shared_context_metadata_behavior = :apply_to_host_groups 46 | 47 | # The settings below are suggested to provide a good initial experience 48 | # with RSpec, but feel free to customize to your heart's content. 49 | =begin 50 | # This allows you to limit a spec run to individual examples or groups 51 | # you care about by tagging them with `:focus` metadata. When nothing 52 | # is tagged with `:focus`, all examples get run. RSpec also provides 53 | # aliases for `it`, `describe`, and `context` that include `:focus` 54 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 55 | config.filter_run_when_matching :focus 56 | 57 | # Allows RSpec to persist some state between runs in order to support 58 | # the `--only-failures` and `--next-failure` CLI options. We recommend 59 | # you configure your source control system to ignore this file. 60 | config.example_status_persistence_file_path = "spec/examples.txt" 61 | 62 | # Limits the available syntax to the non-monkey patched syntax that is 63 | # recommended. For more details, see: 64 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 65 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 66 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 67 | config.disable_monkey_patching! 68 | 69 | # This setting enables warnings. It's recommended, but in some cases may 70 | # be too noisy due to issues in dependencies. 71 | config.warnings = true 72 | 73 | # Many RSpec users commonly either run the entire suite or an individual 74 | # file, and it's useful to allow more verbose output when running an 75 | # individual spec file. 76 | if config.files_to_run.one? 77 | # Use the documentation formatter for detailed output, 78 | # unless a formatter has already been configured 79 | # (e.g. via a command-line flag). 80 | config.default_formatter = "doc" 81 | end 82 | 83 | # Print the 10 slowest examples and example groups at the 84 | # end of the spec run, to help surface which specs are running 85 | # particularly slow. 86 | config.profile_examples = 10 87 | 88 | # Run specs in random order to surface order dependencies. If you find an 89 | # order dependency and want to debug it, you can fix the order by providing 90 | # the seed, which is printed after each run. 91 | # --seed 1234 92 | config.order = :random 93 | 94 | # Seed global randomization in this process using the `--seed` CLI option. 95 | # Setting this allows you to use `--seed` to deterministically reproduce 96 | # test failures related to randomization by passing the same `--seed` value 97 | # as the one that triggered the failure. 98 | Kernel.srand config.seed 99 | =end 100 | end 101 | -------------------------------------------------------------------------------- /spec/timeasure/profiling/manager_spec.rb: -------------------------------------------------------------------------------- 1 | require 'timeasure' 2 | 3 | RSpec.describe Timeasure::Profiling::Manager do 4 | before do 5 | # this emulates 'unpreparing' the Profiling Manager. This way tests are independent regarding their running order. 6 | Timeasure.configuration.reported_methods_handler_set_proc.call(nil) 7 | end 8 | 9 | describe '.report' do 10 | let(:report) { described_class.report(measurement) } 11 | let(:measurement) { double(:measurement) } 12 | 13 | context 'profiling manager is not prepared' do 14 | it 'logs a warning' do 15 | expect_any_instance_of(Logger).to receive(:warn) 16 | report 17 | end 18 | 19 | it 'does not call #report on reported_methods_handler' do 20 | allow_any_instance_of(Logger).to receive(:warn) 21 | expect_any_instance_of(Timeasure::Profiling::ReportedMethodsHandler).not_to receive(:report) 22 | report 23 | end 24 | end 25 | 26 | context 'profiling manager is prepared' do 27 | before do 28 | described_class.prepare 29 | end 30 | 31 | it 'calls #report on reported_methods_handler with measurement as argument' do 32 | expect_any_instance_of(Timeasure::Profiling::ReportedMethodsHandler).to receive(:report).with(measurement) 33 | report 34 | end 35 | end 36 | end 37 | 38 | describe '.export' do 39 | let(:export) { described_class.export } 40 | 41 | context 'profiling manager is not prepared' do 42 | it 'logs a warning' do 43 | expect_any_instance_of(Logger).to receive(:warn) 44 | export 45 | end 46 | 47 | it 'does not call #export on reported_methods_handler' do 48 | allow_any_instance_of(Logger).to receive(:warn) 49 | expect_any_instance_of(Timeasure::Profiling::ReportedMethodsHandler).not_to receive(:export) 50 | export 51 | end 52 | end 53 | 54 | context 'profiling manager is prepared' do 55 | before do 56 | described_class.prepare 57 | end 58 | 59 | it 'calls #export on reported_methods_handler with measurement as argument' do 60 | expect_any_instance_of(Timeasure::Profiling::ReportedMethodsHandler).to receive(:export) 61 | export 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/timeasure/profiling/method_reporting_spec.rb: -------------------------------------------------------------------------------- 1 | require 'timeasure' 2 | 3 | # This spec serves for both Timeasure::Profiling::ReportedMethodsHandler 4 | # and Timeasure::Profiling::ReportedMethod as they represent one unit 5 | 6 | RSpec.describe 'Timeasure::Profiling - Method Reporting' do 7 | let(:reported_methods_handler) { Timeasure::Profiling::ReportedMethodsHandler.new } 8 | 9 | let(:foo_measurement1) do 10 | double(:measurement, runtime_in_milliseconds: 10, full_path: foo_full_path, klass_name: anything, 11 | method_name: anything, segment: anything, metadata: anything, method_path: anything) 12 | end 13 | 14 | let(:foo_measurement2) do 15 | double(:measurement, runtime_in_milliseconds: 20, full_path: foo_full_path, klass_name: anything, 16 | method_name: anything, segment: anything, metadata: anything, method_path: anything) 17 | end 18 | 19 | let(:bar_measurement) do 20 | double(:measurement, runtime_in_milliseconds: 15, full_path: bar_full_path, klass_name: anything, 21 | method_name: anything, segment: anything, metadata: anything, method_path: anything) 22 | end 23 | 24 | let(:foo_full_path) { 'Foo#baz' } 25 | let(:bar_full_path) { 'Bar#baz' } 26 | 27 | let(:all_measurements) { [foo_measurement1, foo_measurement2, bar_measurement] } 28 | 29 | describe '#export' do 30 | let(:export) { reported_methods_handler.export } 31 | 32 | before { all_measurements.each { |measurement| reported_methods_handler.report(measurement) } } 33 | 34 | context 'return value type' do 35 | it 'returns an array of ReportedMethod objects' do 36 | expect(export).to all(be_a Timeasure::Profiling::ReportedMethod) 37 | end 38 | end 39 | 40 | context 'singularity by full path' do 41 | it 'holds a single ReportedMethod objects per Measurement#full_path' do 42 | expect(export.map(&:full_path)).to contain_exactly(*all_measurements.map(&:full_path).uniq) 43 | end 44 | end 45 | 46 | context 'aggregated values' do 47 | let(:foo_reported_method) { export.find { |reported_method| reported_method.full_path == foo_full_path } } 48 | let(:bar_reported_method) { export.find { |reported_method| reported_method.full_path == bar_full_path } } 49 | 50 | it 'aggregates runtime_sum per ReportedMethod' do 51 | expect(foo_reported_method.runtime_sum).to eq(foo_measurement1.runtime_in_milliseconds + 52 | foo_measurement2.runtime_in_milliseconds) 53 | expect(bar_reported_method.runtime_sum).to eq(bar_measurement.runtime_in_milliseconds) 54 | end 55 | 56 | it 'aggregates call_count per ReportedMethod' do 57 | expect(foo_reported_method.call_count).to eq 2 58 | expect(bar_reported_method.call_count).to eq 1 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/timeasure_acceptence_spec.rb: -------------------------------------------------------------------------------- 1 | require 'timeasure' 2 | 3 | RSpec.describe Timeasure do 4 | before(:each) { Timeasure::Profiling::Manager.prepare } 5 | 6 | context 'direct interface' do 7 | describe '.measure' do 8 | let(:klass_name) { double } 9 | let(:method_name) { double } 10 | let(:segment) { double } 11 | let(:metadata) { double } 12 | 13 | context 'proper calls' do 14 | let(:measure) do 15 | described_class.measure(klass_name: klass_name, method_name: method_name, 16 | segment: segment, metadata: metadata) { :some_return_value } 17 | end 18 | 19 | it 'returns the return value of the code block' do 20 | expect(measure).to eq :some_return_value 21 | end 22 | 23 | it 'calls post_measuring_proc' do 24 | expect(Timeasure.configuration.post_measuring_proc).to receive(:call) 25 | measure 26 | end 27 | end 28 | 29 | context 'error handling' do 30 | context 'in the code block itself' do 31 | it 'raises an error normally' do 32 | expect { described_class.measure { raise 'some error in the code block!' } }.to raise_error(RuntimeError) 33 | end 34 | end 35 | 36 | context 'in the post_measuring_proc' do 37 | before do 38 | Timeasure.configure do |configuration| 39 | configuration.post_measuring_proc = lambda do |measurement| 40 | raise RuntimeError 41 | end 42 | end 43 | end 44 | 45 | it 'calls the rescue proc' do 46 | expect(Timeasure.configuration.rescue_proc).to receive(:call) 47 | described_class.measure { :some_return_value } 48 | end 49 | 50 | it 'does not interfere with block return value' do 51 | expect(described_class.measure { :some_return_value }).to eq :some_return_value 52 | end 53 | 54 | after do 55 | Timeasure.configure do |configuration| 56 | configuration.post_measuring_proc = lambda do |measurement| 57 | Timeasure::Profiling::Manager.report(measurement) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | 66 | 67 | context 'DSL interface' do 68 | before(:context) do 69 | # The next section emulates the creation of a new class that includes Timeasure. 70 | # Since using the `Class.new` syntax is obligatory in test environment, 71 | # it has to be assigned to a constant first in order to have a name. 72 | 73 | FirstClass = Class.new 74 | FirstClass.class_eval do 75 | include Timeasure 76 | tracked_class_methods :a_class_method 77 | tracked_instance_methods :a_method, :a_method_with_args, :a_method_with_a_block 78 | 79 | 80 | def self.a_class_method 81 | false 82 | end 83 | 84 | def self.an_untracked_class_method 85 | 'untracked class method' 86 | end 87 | 88 | def a_method 89 | true 90 | end 91 | 92 | def a_method_with_args(arg) 93 | arg 94 | end 95 | 96 | def a_method_with_a_block(&block) 97 | yield 98 | end 99 | 100 | def an_untracked_method 101 | 'untracked method' 102 | end 103 | 104 | end 105 | end 106 | 107 | let(:instance) { FirstClass.new } 108 | 109 | context 'method calling' do 110 | context 'public methods' do 111 | it 'returns methods return values transparently' do 112 | expect(instance.a_method).to eq true 113 | expect(instance.a_method_with_args('arg')).to eq 'arg' 114 | expect(instance.a_method_with_a_block { true ? 8 : 0 }).to eq 8 115 | expect(FirstClass.a_class_method).to eq false 116 | end 117 | end 118 | 119 | context 'private methods' do 120 | # The whole class definition repeats between the two context since there is significance to the fact 121 | # that the tracked methods declaration appears before the actual methods are defined. 122 | 123 | context 'when declaration is proper' do 124 | before(:context) do 125 | FirstClass.class_eval do 126 | tracked_class_methods :a_class_method_that_calls_private_methods 127 | tracked_private_class_methods :a_scoped_private_class_method, :an_inline_private_class_method 128 | tracked_instance_methods :a_method_that_calls_private_methods 129 | tracked_private_instance_methods :a_scoped_private_method, :an_inline_private_method 130 | 131 | class << self 132 | def a_class_method_that_calls_private_methods 133 | a_scoped_private_class_method 134 | an_inline_private_class_method 135 | end 136 | 137 | private 138 | 139 | def a_scoped_private_class_method 140 | :class_private_stuff 141 | end 142 | end 143 | 144 | private_class_method def self.an_inline_private_class_method 145 | :more_class_private_stuff 146 | end 147 | 148 | def a_method_that_calls_private_methods 149 | a_scoped_private_method 150 | an_inline_private_method 151 | end 152 | 153 | private def a_scoped_private_method 154 | :instance_private_stuff 155 | end 156 | 157 | private 158 | 159 | def an_inline_private_method 160 | :more_instance_private_stuff 161 | end 162 | end 163 | end 164 | 165 | it 'returns methods return values transparently' do 166 | expect(FirstClass.a_class_method_that_calls_private_methods).to eq(:more_class_private_stuff) 167 | expect(instance.a_method_that_calls_private_methods).to eq(:more_instance_private_stuff) 168 | end 169 | 170 | it 'keeps private methods as private' do 171 | expect { FirstClass.a_scoped_private_class_method }.to raise_error(NoMethodError) 172 | expect { FirstClass.an_inline_private_class_method }.to raise_error(NoMethodError) 173 | expect { instance.a_scoped_private_instance_method }.to raise_error(NoMethodError) 174 | expect { instance.an_inline_private_instance_method }.to raise_error(NoMethodError) 175 | end 176 | end 177 | 178 | context 'when declaration is improper' do 179 | before do 180 | FirstClass.class_eval do 181 | tracked_class_methods :a_class_method_that_calls_private_methods, :a_scoped_private_class_method, 182 | :an_inline_private_class_method 183 | tracked_instance_methods :a_method_that_calls_private_methods, :a_scoped_private_method, 184 | :an_inline_private_method 185 | 186 | class << self 187 | def a_class_method_that_calls_private_methods 188 | a_scoped_private_class_method 189 | an_inline_private_class_method 190 | end 191 | 192 | private 193 | 194 | def a_scoped_private_class_method 195 | :class_private_stuff 196 | end 197 | end 198 | 199 | private_class_method def self.an_inline_private_class_method 200 | :more_class_private_stuff 201 | end 202 | 203 | def a_method_that_calls_private_methods 204 | a_scoped_private_method 205 | an_inline_private_method 206 | end 207 | 208 | private def a_scoped_private_method 209 | :instance_private_stuff 210 | end 211 | 212 | private 213 | 214 | def an_inline_private_method 215 | :more_instance_private_stuff 216 | end 217 | end 218 | end 219 | 220 | it 'raises NoMethodError' do 221 | expect { FirstClass.a_class_method_that_calls_private_methods }.to raise_error(NoMethodError) 222 | expect { instance.a_method_that_calls_private_methods }.to raise_error(NoMethodError) 223 | end 224 | end 225 | end 226 | end 227 | 228 | context 'triggering Timeasure' do 229 | it 'calls Timeasure.measure for tracked methods' do 230 | expect(Timeasure).to receive(:measure).exactly(2).times 231 | instance.a_method 232 | FirstClass.a_class_method 233 | end 234 | 235 | it 'does not call Timeasure.measure for untracked methods' do 236 | expect(Timeasure).not_to receive(:measure) 237 | instance.an_untracked_method 238 | FirstClass.an_untracked_class_method 239 | end 240 | end 241 | end 242 | 243 | context 'profiler' do 244 | context 'reporting' do 245 | it 'reports each call trough Timeasure::Profiling::Manager.report' do 246 | expect(Timeasure::Profiling::Manager).to receive(:report).exactly(7).times 247 | 248 | 2.times do 249 | Timeasure.measure(klass_name: 'Foo', method_name: 'bar') { :some_return_value } 250 | end 251 | 252 | 5.times do 253 | Timeasure.measure(klass_name: 'Baz', method_name: 'qux') { :some_other_return_value } 254 | end 255 | end 256 | end 257 | 258 | context 'exporting' do 259 | before do 260 | 3.times do 261 | Timeasure.measure(klass_name: 'Foo', method_name: 'bar') { :some_return_value } 262 | end 263 | 264 | 4.times do 265 | Timeasure.measure(klass_name: 'Baz', method_name: 'qux') { :some_other_return_value } 266 | end 267 | end 268 | 269 | let(:export) { Timeasure::Profiling::Manager.export } 270 | 271 | it 'exports all calls in an aggregated manner' do 272 | expect(export.count).to eq 2 273 | end 274 | end 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /timeasure.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'timeasure/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'timeasure' 7 | spec.version = Timeasure::VERSION 8 | spec.authors = ['Eliav Lavi'] 9 | spec.email = ['eliav@riskified.com', 'eliavlavi@gmail.com'] 10 | spec.summary = 'Transparent method-level wrapper for profiling purposes' 11 | spec.description = <<-DESCRIPTION 12 | Timeasure is a Ruby gem that allows measuring the runtime of methods 13 | without having to alter the code of the methods themselves. 14 | DESCRIPTION 15 | spec.homepage = 'https://github.com/riskified/timeasure' 16 | spec.license = 'MIT' 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.start_with? 'spec/' } 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.test_files = spec.files.grep(%r{^spec/}) 20 | spec.require_paths = ['lib', 'lib/timeasure', 'lib/timeasure/profiling'] 21 | 22 | spec.required_ruby_version = '>= 2.1' 23 | spec.add_development_dependency 'bundler', '~> 1.6' 24 | spec.add_development_dependency 'coveralls' 25 | spec.add_development_dependency 'rake', '~> 12.0' 26 | spec.add_development_dependency 'rspec', '~> 3.6' 27 | end 28 | --------------------------------------------------------------------------------