├── .gitignore ├── init.rb ├── Gemfile ├── test ├── lib │ └── trashed │ │ └── test_helper.rb ├── resource_usage_test.rb ├── meter_test.rb ├── rack_test.rb ├── ruby_gc_profiler_test.rb └── reporter_test.rb ├── lib ├── trashed.rb └── trashed │ ├── instruments │ ├── object_space_counter.rb │ ├── ruby_gc_profiler.rb │ ├── gctools_oobgc.rb │ ├── ree_gc.rb │ ├── ruby_gc.rb │ └── stopwatch.rb │ ├── rack.rb │ ├── railtie.rb │ ├── resource_usage.rb │ ├── meter.rb │ └── reporter.rb ├── Rakefile ├── Gemfile.lock ├── trashed.gemspec ├── MIT-LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'trashed' 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /test/lib/trashed/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'trashed' 2 | require 'minitest/autorun' 3 | -------------------------------------------------------------------------------- /lib/trashed.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/rack' 2 | require 'trashed/railtie' if defined? ::Rails::Railtie 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | desc 'Default: run tests' 4 | task :default => :test 5 | 6 | desc 'Run tests' 7 | Rake::TestTask.new :test do |t| 8 | t.libs << 'test/lib' 9 | t.pattern = 'test/*_test.rb' 10 | t.verbose = true 11 | end 12 | -------------------------------------------------------------------------------- /lib/trashed/instruments/object_space_counter.rb: -------------------------------------------------------------------------------- 1 | module Trashed 2 | module Instruments 3 | class ObjectSpaceCounter 4 | def measure(state, timings, gauges) 5 | ObjectSpace.count_objects.each do |type, count| 6 | gauges << [ :"Objects.#{type}", count ] 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | trashed (3.2.7) 5 | statsd-ruby (~> 1.1) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | minitest (5.10.2) 11 | rake (12.3.3) 12 | statsd-ruby (1.4.0) 13 | 14 | PLATFORMS 15 | ruby 16 | 17 | DEPENDENCIES 18 | minitest (~> 5.3) 19 | rake (~> 12) 20 | trashed! 21 | 22 | BUNDLED WITH 23 | 1.16.0 24 | -------------------------------------------------------------------------------- /trashed.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'trashed' 3 | s.version = '3.2.8' 4 | s.license = 'MIT' 5 | s.summary = 'Rack request GC stats => logs + StatsD' 6 | s.description = 'Each Rack request eats up time, objects, and GC. Report usage to logs and StatsD.' 7 | 8 | s.homepage = 'https://github.com/basecamp/trashed' 9 | s.author = 'Jeremy Daer' 10 | s.email = 'jeremydaer@gmail.com' 11 | 12 | s.add_runtime_dependency 'statsd-ruby', '~> 1.1' 13 | 14 | s.add_development_dependency 'rake', '~> 12' 15 | s.add_development_dependency 'minitest', '~> 5.3' 16 | 17 | root = File.dirname(__FILE__) 18 | s.files = [ "#{root}/init.rb" ] + Dir["#{root}/lib/**/*"] 19 | end 20 | -------------------------------------------------------------------------------- /test/resource_usage_test.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/test_helper' 2 | 3 | class ResourceUsageTest < Minitest::Test 4 | def setup 5 | super 6 | @meter = Trashed::ResourceUsage 7 | end 8 | 9 | def test_wall_time 10 | assert_in_delta 0, timing(:'Time.wall'), 1000 11 | end 12 | 13 | if Process.respond_to?(:clock_gettime) 14 | def test_cpu_and_idle_time 15 | assert_in_delta 0, timing(:'Time.cpu'), 1000 16 | assert_in_delta 0, timing(:'Time.idle'), 1000 17 | assert timing(:'Time.pct.cpu') 18 | assert timing(:'Time.pct.idle') 19 | end 20 | end 21 | 22 | private 23 | def timing(metric) 24 | state = { :persistent => {} } 25 | timings = {} 26 | assert_equal :result, @meter.instrument!(state, timings, []) { :result } 27 | assert timings.include?(metric), timings.inspect 28 | timings[metric] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/trashed/instruments/ruby_gc_profiler.rb: -------------------------------------------------------------------------------- 1 | module Trashed 2 | module Instruments 3 | class RubyGCProfiler 4 | # Captures out-of-band GC time and stats. 5 | def start(state, timings, gauges) 6 | GC::Profiler.enable 7 | measure state, timings, gauges, :OOBGC 8 | end 9 | 10 | # Captures in-band GC time and stats. 11 | def measure(state, timings, gauges, captured = :GC) 12 | timings[:"#{captured}.time"] ||= 1000 * GC::Profiler.total_time 13 | 14 | if GC::Profiler.respond_to? :raw_data 15 | timings[:"#{captured}.count"] ||= GC::Profiler.raw_data.size 16 | timings[:'GC.interval'] = GC::Profiler.raw_data.map { |data| 1000 * data[:GC_INVOKE_TIME] } 17 | end 18 | 19 | # Clears .total_time and .raw_data 20 | GC::Profiler.clear 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/trashed/instruments/gctools_oobgc.rb: -------------------------------------------------------------------------------- 1 | module Trashed 2 | module Instruments 3 | # Tracks out of band GCs that occurred *since* the last request. 4 | class GctoolsOobgc 5 | def start(state, timings, gauges) 6 | last = state[:persistent][:oobgc] || Hash.new(0) 7 | 8 | current = { 9 | :count => GC::OOB.stat(:count).to_i, 10 | :major => GC::OOB.stat(:major).to_i, 11 | :minor => GC::OOB.stat(:minor).to_i, 12 | :sweep => GC::OOB.stat(:sweep).to_i } 13 | 14 | timings.update \ 15 | :'OOBGC.count' => current[:count] - last[:count], 16 | :'OOBGC.major_count' => current[:major] - last[:major], 17 | :'OOBGC.minor_count' => current[:minor] - last[:minor], 18 | :'OOBGC.sweep_count' => current[:sweep] - last[:sweep] 19 | 20 | state[:persistent][:oobgc] = current 21 | end 22 | 23 | def measure(state, timings, gauges) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/trashed/rack.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/resource_usage' 2 | 3 | module Trashed 4 | class Rack 5 | STATE, TIMINGS, GAUGES = 'trashed.state', 'trashed.timings', 'trashed.gauges' 6 | 7 | def initialize(app, reporter, options = {}) 8 | @reporter = reporter 9 | @meters = Array(options.fetch(:meters, [ResourceUsage])) 10 | @app = build_instrumented_app(app, @meters) 11 | end 12 | 13 | def call(env) 14 | env[STATE] = { :persistent => persistent_thread_state } 15 | env[TIMINGS] = {} 16 | env[GAUGES] = [] 17 | 18 | @app.call(env).tap { @reporter.report env } 19 | end 20 | 21 | private 22 | def persistent_thread_state 23 | Thread.current[:trashed_rack_state] ||= {} 24 | end 25 | 26 | def build_instrumented_app(app, meters) 27 | meters.inject app do |wrapped, meter| 28 | lambda do |env| 29 | meter.instrument! env[STATE], env[TIMINGS], env[GAUGES] do 30 | wrapped.call env 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/trashed/instruments/ree_gc.rb: -------------------------------------------------------------------------------- 1 | module Trashed 2 | module Instruments 3 | class Ruby18GC 4 | def initialize 5 | GC.enable_stats 6 | end 7 | 8 | def start(state, timings, gauges) 9 | state[:ruby18_gc] = { 10 | :objects => ObjectSpace.allocated_objects, 11 | :gc_count => GC.collections, 12 | :gc_time => GC.time, 13 | :gc_memory => GC.allocated_size } 14 | end 15 | 16 | def measure(state, timings, gauges) 17 | before = state[:ruby18_gc] 18 | 19 | timings.update \ 20 | :'GC.count' => GC.collections - before[:gc_count], 21 | :'GC.time' => (GC.time - before[:gc_time]) / 1000.0, 22 | :'GC.memory' => GC.allocated_size - before[:gc_memory], 23 | :'GC.allocated_objects' => ObjectSpace.allocated_objects - before[:objects] 24 | 25 | gauges << [ :'Objects.live', ObjectSpace.live_objects ] 26 | gauges << [ :'GC.growth', GC.growth ] 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 37signals, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/meter_test.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/test_helper' 2 | 3 | class MeterTest < Minitest::Test 4 | def test_counts 5 | meter = Trashed::Meter.new 6 | i = 0 7 | meter.counts(:foo) { i += 1 } 8 | 9 | timings = {} 10 | assert_equal :result, meter.instrument!({}, timings, []) { :result } 11 | assert_equal 1, timings[:foo] 12 | end 13 | 14 | def test_gauges 15 | meter = Trashed::Meter.new 16 | meter.gauges(:foo) { 1 } 17 | 18 | gauges = [] 19 | assert_equal :result, meter.instrument!({}, [], gauges) { :result } 20 | assert_equal [[ :foo, 1 ]], gauges 21 | end 22 | 23 | def test_instruments 24 | i = Object.new 25 | def i.start(state, timings, gauges) state[:foo] = 10 end 26 | def i.measure(state, timings, gauges) 27 | timings[:foo] = state.delete(:foo) - 2 28 | gauges << [ :bar, 2 ] 29 | end 30 | meter = Trashed::Meter.new 31 | meter.instrument i 32 | 33 | timings, gauges = {}, [] 34 | assert_equal :result, meter.instrument!({}, timings, gauges) { :result } 35 | assert_equal 8, timings[:foo] 36 | assert_equal [[ :bar, 2 ]], gauges 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/trashed/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'rails/railtie' 2 | require 'trashed/rack' 3 | require 'trashed/reporter' 4 | 5 | module Trashed 6 | class Railtie < ::Rails::Railtie 7 | config.trashed = Trashed::Reporter.new 8 | 9 | # Middleware would like to emit tagged logs after Rails::Rack::Logger 10 | # pops its tags. Introduce this haxware to stash the tags in the Rack 11 | # env so we can reuse them later. 12 | class ExposeLoggerTagsToRackEnv 13 | def initialize(app) 14 | @app = app 15 | end 16 | 17 | def call(env) 18 | @app.call(env).tap do 19 | env['trashed.logger.tags'] = Array(Thread.current[:activesupport_tagged_logging_tags]).dup 20 | end 21 | end 22 | end 23 | 24 | initializer 'trashed' do |app| 25 | require 'statsd' 26 | 27 | app.config.trashed.timing_sample_rate ||= 0.1 28 | app.config.trashed.gauge_sample_rate ||= 0.05 29 | app.config.trashed.logger ||= Rails.logger 30 | 31 | app.middleware.insert_after ::Rack::Runtime, Trashed::Rack, app.config.trashed 32 | app.middleware.insert_after ::Rails::Rack::Logger, ExposeLoggerTagsToRackEnv 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/rack_test.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/test_helper' 2 | 3 | class RackTest < Minitest::Test 4 | Hello = lambda { |env| [200, {}, %w(hello)] } 5 | 6 | def setup 7 | @reporter = Object.new 8 | def @reporter.report(env) end 9 | def @reporter.request_reporting_rate; 1 end 10 | def @reporter.gauge_sample_rate; 1 end 11 | end 12 | 13 | def test_instruments_app_and_stores_in_env 14 | env = {} 15 | response = Trashed::Rack.new(Hello, @reporter).call(env) 16 | refute_nil env[Trashed::Rack::STATE] 17 | refute_nil env[Trashed::Rack::STATE][:persistent] 18 | refute_nil env[Trashed::Rack::TIMINGS] 19 | refute_nil env[Trashed::Rack::TIMINGS][:'Time.wall'] 20 | refute_nil env[Trashed::Rack::GAUGES] 21 | end 22 | 23 | def test_persistent_thread_state 24 | app = lambda { |env| env[Trashed::Rack::STATE][:persistent][:foo] = env[Trashed::Rack::STATE][:persistent][:foo].to_i + 1 } 25 | rack = Trashed::Rack.new(app, @reporter) 26 | 27 | env = {} 28 | rack.call env 29 | assert_equal 1, env[Trashed::Rack::STATE][:persistent][:foo] 30 | 31 | rack.call env 32 | assert_equal 2, env[Trashed::Rack::STATE][:persistent][:foo] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/trashed/resource_usage.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/meter' 2 | 3 | module Trashed 4 | ResourceUsage = Meter.new.tap do |meter| 5 | # Wall clock time in milliseconds since epoch. 6 | # Includes CPU and idle time on Ruby 2.1+. 7 | require 'trashed/instruments/stopwatch' 8 | meter.instrument Trashed::Instruments::Stopwatch.new 9 | 10 | # RailsBench GC patch / REE 1.8 11 | if GC.respond_to? :enable_stats 12 | require 'trashed/instruments/ree_gc' 13 | meter.instrument Trashed::Instruments::Ruby18GC.new 14 | end 15 | 16 | # Ruby 1.9+ 17 | if ObjectSpace.respond_to? :count_objects 18 | require 'trashed/instruments/object_space_counter' 19 | meter.instrument Trashed::Instruments::ObjectSpaceCounter.new 20 | end 21 | 22 | # Ruby 1.9+ 23 | if GC.respond_to?(:stat) 24 | require 'trashed/instruments/ruby_gc' 25 | meter.instrument Trashed::Instruments::RubyGC.new 26 | end 27 | 28 | # Ruby 1.9+ 29 | if defined? GC::Profiler 30 | require 'trashed/instruments/ruby_gc_profiler' 31 | meter.instrument Trashed::Instruments::RubyGCProfiler.new 32 | end 33 | 34 | # Ruby 2.1+ with https://github.com/tmm1/gctools 35 | if defined? GC::OOB 36 | require 'trashed/instruments/gctools_oobgc' 37 | meter.instrument Trashed::Instruments::GctoolsOobgc.new 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/ruby_gc_profiler_test.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/test_helper' 2 | require 'trashed/instruments/ruby_gc_profiler' 3 | 4 | if defined? GC::Profiler 5 | class RubyGCProfilerTest < Minitest::Test 6 | def setup 7 | super 8 | @instrument = Trashed::Instruments::RubyGCProfiler.new 9 | GC::Profiler.enable 10 | GC::Profiler.clear 11 | end 12 | 13 | def teardown 14 | GC::Profiler.disable 15 | end 16 | 17 | def test_records_out_of_band_gc_count_and_time 18 | assert_records_gc_count_and_time :start, :OOBGC 19 | end 20 | 21 | def test_records_gc_count_and_time 22 | assert_records_gc_count_and_time :measure, :GC 23 | end 24 | 25 | private 26 | def assert_records_gc_count_and_time(method, captured) 27 | GC.start 28 | GC.start 29 | 30 | elapsed = GC::Profiler.total_time 31 | 32 | if GC::Profiler.respond_to? :raw_data 33 | intervals = GC::Profiler.raw_data.map { |d| d[:GC_INVOKE_TIME] } 34 | end 35 | 36 | timings, gauges = {}, [] 37 | @instrument.send method, nil, timings, gauges 38 | 39 | assert_equal 1000 * elapsed, timings[:"#{captured}.time"] 40 | 41 | if GC::Profiler.respond_to? :raw_data 42 | assert_equal 2, timings[:"#{captured}.count"] 43 | 44 | assert_equal intervals.map { |i| 1000 * i }, timings[:'GC.interval'] 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/trashed/instruments/ruby_gc.rb: -------------------------------------------------------------------------------- 1 | module Trashed 2 | module Instruments 3 | class RubyGC 4 | def start(state, timings, gauges) 5 | state[:ruby_gc] = GC.stat 6 | end 7 | 8 | MEASUREMENTS = { 9 | :count => :'GC.count' 10 | } 11 | 12 | RUBY_2X_MEASUREMENTS = { 13 | :major_gc_count => :'GC.major_count', 14 | :minor_gc_count => :'GC.minor_gc_count' 15 | } 16 | 17 | # Detect Ruby 1.9, 2.1 or 2.2 GC.stat naming 18 | begin 19 | GC.stat :total_allocated_objects 20 | rescue TypeError 21 | # Ruby 1.9, nothing to do 22 | rescue ArgumentError 23 | # Ruby 2.1 24 | MEASUREMENTS.update \ 25 | RUBY_2X_MEASUREMENTS.merge( 26 | :total_allocated_object => :'GC.allocated_objects', 27 | :total_freed_object => :'GC.freed_objects' 28 | ) 29 | else 30 | # Ruby 2.2+ 31 | MEASUREMENTS.update \ 32 | RUBY_2X_MEASUREMENTS.merge( 33 | :total_allocated_objects => :'GC.allocated_objects', 34 | :total_freed_objects => :'GC.freed_objects' 35 | ) 36 | end 37 | 38 | def measure(state, timings, gauges) 39 | gc = GC.stat 40 | before = state[:ruby_gc] 41 | 42 | MEASUREMENTS.each do |stat, metric| 43 | timings[metric] = gc[stat] - before[stat] if gc.include? stat 44 | end 45 | 46 | gauges.concat gc.map { |k, v| [ :"GC.#{k}", v ] } 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/trashed/meter.rb: -------------------------------------------------------------------------------- 1 | module Trashed 2 | class Meter 3 | attr_reader :instruments 4 | 5 | def initialize 6 | @timers = [] 7 | @gauges = [] 8 | end 9 | 10 | # Counters increase, so we measure before/after differences. 11 | # Time elapsed, memory growth, objects allocated, etc. 12 | def counts(name, &block) 13 | instrument ChangeInstrument.new(name, block) 14 | end 15 | 16 | # Gauges measure point-in-time values. 17 | # Heap size, live objects, GC count, etc. 18 | def gauges(name, &block) 19 | instrument GaugeInstrument.new(name, block) 20 | end 21 | 22 | def instrument(instrument) 23 | if instrument.respond_to?(:start) 24 | @timers << instrument 25 | else 26 | @gauges << instrument 27 | end 28 | end 29 | 30 | def instrument!(state, timings, gauges) 31 | @timers.each { |i| i.start state, timings, gauges } 32 | yield.tap do 33 | @timers.reverse_each { |i| i.measure state, timings, gauges } 34 | @gauges.each { |i| i.measure state, timings, gauges } 35 | end 36 | end 37 | 38 | class ChangeInstrument 39 | def initialize(name, probe) 40 | @name, @probe = name, probe 41 | end 42 | 43 | def start(state, timings, gauges) 44 | state[@name] = @probe.call 45 | end 46 | 47 | def measure(state, timings, gauges) 48 | timings[@name] = @probe.call - state[@name] 49 | end 50 | end 51 | 52 | class GaugeInstrument 53 | def initialize(name, probe) 54 | @name, @probe = name, probe 55 | end 56 | 57 | def measure(state, timings, gauges) 58 | gauges << [ @name, @probe.call ] 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/trashed/instruments/stopwatch.rb: -------------------------------------------------------------------------------- 1 | module Trashed 2 | module Instruments 3 | class Stopwatch 4 | def initialize(timepiece = Timepiece) 5 | @timepiece = timepiece 6 | @has_cpu_time = timepiece.respond_to?(:cpu) 7 | end 8 | 9 | def start(state, timings, gauges) 10 | state[:stopwatch_wall] = @timepiece.wall 11 | state[:stopwatch_cpu] = @timepiece.cpu if @has_cpu_time 12 | end 13 | 14 | def measure(state, timings, gauges) 15 | wall_elapsed = @timepiece.wall - state.delete(:stopwatch_wall) 16 | timings[:'Time.wall'] = wall_elapsed 17 | if @has_cpu_time 18 | cpu_elapsed = @timepiece.cpu - state.delete(:stopwatch_cpu) 19 | idle_elapsed = wall_elapsed - cpu_elapsed 20 | 21 | timings[:'Time.cpu'] = cpu_elapsed 22 | timings[:'Time.idle'] = idle_elapsed 23 | 24 | if wall_elapsed == 0 25 | timings[:'Time.pct.cpu'] = 0 26 | timings[:'Time.pct.idle'] = 0 27 | else 28 | timings[:'Time.pct.cpu'] = 100.0 * cpu_elapsed / wall_elapsed 29 | timings[:'Time.pct.idle'] = 100.0 * idle_elapsed / wall_elapsed 30 | end 31 | end 32 | end 33 | end 34 | 35 | module Timepiece 36 | def self.wall 37 | ::Time.now.to_f * 1000 38 | end 39 | 40 | # Ruby 2.1+ 41 | if Process.respond_to?(:clock_gettime) 42 | def self.cpu 43 | Process.clock_gettime Process::CLOCK_PROCESS_CPUTIME_ID, :float_millisecond 44 | end 45 | 46 | # ruby-prof installed 47 | elsif defined? RubyProf::Measure::ProcessTime 48 | def self.cpu 49 | RubyProf::Measure::Process.measure * 1000 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/reporter_test.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/test_helper' 2 | require 'trashed/reporter' 3 | require 'logger' 4 | require 'stringio' 5 | 6 | class ReporterTest < Minitest::Test 7 | def setup 8 | @reporter = Trashed::Reporter.new 9 | @reporter.timing_sample_rate = 1 10 | @reporter.gauge_sample_rate = 1 11 | end 12 | 13 | def test_sample_rate_defaults 14 | assert_equal 0.1, Trashed::Reporter.new.timing_sample_rate 15 | assert_equal 0.05, Trashed::Reporter.new.gauge_sample_rate 16 | end 17 | 18 | def test_report_logger 19 | assert_report_logs 'Rack handled in 1.00ms.' 20 | assert_report_logs 'Rack handled in 1.00ms (9.9% cpu, 90.1% idle).', :'Time.pct.cpu' => 9.9, :'Time.pct.idle' => 90.1 21 | 22 | assert_report_logs 'Rack handled in 1.00ms.', :'GC.allocated_objects' => 0 23 | assert_report_logs 'Rack handled in 1.00ms. 10 objects.', :'GC.allocated_objects' => 10 24 | 25 | assert_report_logs 'Rack handled in 1.00ms. 0 GCs.', :'GC.count' => 0 26 | assert_report_logs 'Rack handled in 1.00ms. 2 GCs.', :'GC.count' => 2 27 | assert_report_logs 'Rack handled in 1.00ms. 2 GCs (3 major, 4 minor).', :'GC.count' => 2, :'GC.major_count' => 3, :'GC.minor_count' => 4 28 | assert_report_logs 'Rack handled in 1.00ms. 2 GCs took 10.00ms.', :'GC.count' => 2, :'GC.time' => 10 29 | 30 | assert_report_logs 'Rack handled in 1.00ms.', :'OOBGC.count' => 0 31 | assert_report_logs 'Rack handled in 1.00ms. 0 GCs. Avoided 3 OOB GCs.', :'OOBGC.count' => 3 32 | assert_report_logs 'Rack handled in 1.00ms. 0 GCs. Avoided 3 OOB GCs (4 major, 5 minor, 6 sweep).', :'OOBGC.count' => 3, :'OOBGC.major_count' => 4, :'OOBGC.minor_count' => 5, :'OOBGC.sweep_count' => 6 33 | assert_report_logs 'Rack handled in 1.00ms. 0 GCs. Avoided 3 OOB GCs saving 10.00ms.', :'OOBGC.count' => 3, :'OOBGC.time' => 10 34 | 35 | assert_report_logs 'Rack handled in 1.00ms (9.1% cpu, 90.1% idle). 10 objects. 2 GCs (3 major, 4 minor) took 10.00ms. Avoided 3 OOB GCs (4 major, 5 minor, 6 sweep) saving 10.00ms.', 36 | :'Time.pct.cpu' => 9.1, :'Time.pct.idle' => 90.1, 37 | :'GC.allocated_objects' => 10, 38 | :'GC.count' => 2, :'GC.time' => 10, 39 | :'GC.major_count' => 3, :'GC.minor_count' => 4, 40 | :'OOBGC.count' => 3, :'OOBGC.time' => 10, 41 | :'OOBGC.major_count' => 4, :'OOBGC.minor_count' => 5, :'OOBGC.sweep_count' => 6 42 | end 43 | 44 | def test_tagged_logger 45 | @reporter.logger = logger = Logger.new(out = StringIO.new) 46 | class << logger 47 | attr_reader :tags 48 | def tagged(tags) @tags = tags; yield end 49 | end 50 | 51 | @reporter.report_logger 'trashed.logger.tags' => %w(a b c), 'trashed.timings' => { :'Time.wall' => 1 } 52 | assert_match 'Rack handled in 1.00ms.', out.string 53 | assert_equal %w(a b c), logger.tags 54 | end 55 | 56 | private 57 | def assert_report_logs(string, timings = {}) 58 | @reporter.logger = Logger.new(out = StringIO.new) 59 | @reporter.report_logger 'trashed.timings' => timings.merge(:'Time.wall' => 1) 60 | assert_match string, out.string 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/trashed/reporter.rb: -------------------------------------------------------------------------------- 1 | require 'trashed/rack' 2 | 3 | module Trashed 4 | class Reporter 5 | attr_accessor :logger, :statsd 6 | attr_accessor :timing_sample_rate, :gauge_sample_rate 7 | attr_accessor :timing_dimensions, :gauge_dimensions 8 | 9 | DEFAULT_DIMENSIONS = [ :All ] 10 | 11 | def initialize 12 | @logger = nil 13 | @statsd = nil 14 | @timing_sample_rate = 0.1 15 | @gauge_sample_rate = 0.05 16 | @timing_dimensions = lambda { |env| DEFAULT_DIMENSIONS } 17 | @gauge_dimensions = lambda { |env| DEFAULT_DIMENSIONS } 18 | end 19 | 20 | def report(env) 21 | report_logger env if @logger 22 | report_statsd env if @statsd 23 | end 24 | 25 | def report_logger(env) 26 | timings = env[Trashed::Rack::TIMINGS] 27 | parts = [] 28 | 29 | elapsed = '%.2fms' % timings[:'Time.wall'] 30 | if timings[:'Time.pct.cpu'] 31 | elapsed << ' (%.1f%% cpu, %.1f%% idle)' % timings.values_at(:'Time.pct.cpu', :'Time.pct.idle') 32 | end 33 | parts << elapsed 34 | 35 | obj = timings[:'GC.allocated_objects'].to_i 36 | parts << '%d objects' % obj unless obj.zero? 37 | 38 | if gcs = timings[:'GC.count'].to_i 39 | gc = '%d GCs' % gcs 40 | unless gcs.zero? 41 | if timings.include?(:'GC.major_count') 42 | gc << ' (%d major, %d minor)' % timings.values_at(:'GC.major_count', :'GC.minor_count').map(&:to_i) 43 | end 44 | if timings.include?(:'GC.time') 45 | gc << ' took %.2fms' % timings[:'GC.time'] 46 | end 47 | end 48 | parts << gc 49 | end 50 | 51 | oobgcs = timings[:'OOBGC.count'].to_i 52 | if !oobgcs.zero? 53 | oobgc = 'Avoided %d OOB GCs' % oobgcs 54 | if timings[:'OOBGC.major_count'] 55 | oobgc << ' (%d major, %d minor, %d sweep)' % timings.values_at(:'OOBGC.major_count', :'OOBGC.minor_count', :'OOBGC.sweep_count').map(&:to_i) 56 | end 57 | if timings[:'OOBGC.time'] 58 | oobgc << ' saving %.2fms' % timings[:'OOBGC.time'] 59 | end 60 | parts << oobgc 61 | end 62 | 63 | message = "Rack handled in #{parts * '. '}." 64 | 65 | if @logger.respond_to?(:tagged) && env.include?('trashed.logger.tags') 66 | @logger.tagged env['trashed.logger.tags'] do 67 | @logger.info message 68 | end 69 | else 70 | @logger.info message 71 | end 72 | end 73 | 74 | def report_statsd(env) 75 | method = @statsd.respond_to?(:easy) ? :easy : :batch 76 | @statsd.send(method) do |statsd| 77 | send_to_statsd statsd, :timing, @timing_sample_rate, env[Trashed::Rack::TIMINGS], :'Rack.Request', @timing_dimensions.call(env) 78 | send_to_statsd statsd, :timing, @gauge_sample_rate, env[Trashed::Rack::GAUGES], :'Rack.Server', @gauge_dimensions.call(env) 79 | end 80 | end 81 | 82 | def send_to_statsd(statsd, method, sample_rate, measurements, namespace, dimensions) 83 | measurements.each do |metric, value| 84 | case value 85 | when Array 86 | value.each do |v| 87 | send_to_statsd statsd, method, sample_rate, { metric => v }, namespace, dimensions 88 | end 89 | when Numeric 90 | Array(dimensions || :All).each do |dimension| 91 | statsd.send method, :"#{namespace}.#{dimension}.#{metric}", value, sample_rate 92 | end 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Trashed 2 | # Keep an eye on resource usage. 3 | 4 | 5 | - Sends per-request object counts, heap growth, GC time, and more to StatsD. 6 | - Sends snapshots of resource usage, e.g. live String objects, to StatsD. 7 | - Supports new stuff: Rails 5.1 and latest Ruby 2.x features. 8 | - Supports old stuff: Rails 2/3/4, Ruby 1.9+, REE, Ruby 1.8 with RubyBench patches. 9 | 10 | ## Setup 11 | 12 | ### Rails 5 13 | 14 | On Rails 5 (and Rails 3 and 4), add this to the top of `config/application.rb`: 15 | 16 | require 'trashed/railtie' 17 | 18 | And in the body of your app config: 19 | 20 | module YourApp 21 | class Application < Rails::Application 22 | config.trashed.statsd = YourApp.statsd 23 | 24 | 25 | ### Rails 2 26 | 27 | On Rails 2, add the middleware to `config/environment.rb`: 28 | 29 | Rails::Initializer.run do |config| 30 | reporter = Trashed::Reporter.new 31 | reporter.logger = Rails.logger 32 | reporter.statsd = YourApp.statsd 33 | 34 | config.middleware.use Trashed::Rack, reporter 35 | end 36 | 37 | 38 | ### Custom dimensions 39 | 40 | You probably want stats per controller, action, right? 41 | 42 | Set a `#timing_dimensions` lambda to return a list of dimensions to 43 | qualify per-request measurements like time elapsed, GC time, objects 44 | allocated, etc. 45 | 46 | For example: 47 | ```ruby 48 | config.trashed.timing_dimensions = ->(env) do 49 | # Rails 3, 4, and 5, set this. Other Rack endpoints won't have it. 50 | if controller = env['action_controller.instance'] 51 | name = controller.controller_name 52 | action = controller.action_name 53 | format = controller.rendered_format || :none 54 | variant = controller.request.variant || :none # Rails 4.1+ only! 55 | 56 | [ :All, 57 | :"Controllers.#{name}", 58 | :"Actions.#{name}.#{action}.#{format}+#{variant}" ] 59 | end 60 | end 61 | ``` 62 | 63 | Results in metrics like: 64 | ``` 65 | YourNamespace.All.Time.wall 66 | YourNamespace.Controllers.SessionsController.Time.wall 67 | YourNamespace.Actions.SessionsController.index.json+phone.Time.wall 68 | ``` 69 | 70 | 71 | Similarly, set a `#gauge_dimensions` lambda to return a list of dimensions to 72 | qualify measurements which gauge current state, like heap slots used or total 73 | number of live String objects. 74 | 75 | For example: 76 | 77 | ```ruby 78 | config.trashed.gauge_dimensions = ->(env) { 79 | [ :All, 80 | :"Stage.#{Rails.env}", 81 | :"Hosts.#{`hostname -s`.chomp}" ] 82 | } 83 | ``` 84 | 85 | Results in metrics like: 86 | ``` 87 | YourNamespace.All.Objects.T_STRING 88 | YourNamespace.Stage.production.Objects.T_STRING 89 | YourNamespace.Hosts.host-001.Objects.T_STRING 90 | ``` 91 | 92 | 93 | ### Version history 94 | 95 | *3.2.8* (January 31, 2022) 96 | 97 | * REE: Fix that GC.time is reported in microseconds instead of milliseconds 98 | 99 | *3.2.7* (November 8, 2017) 100 | 101 | * Ruby 1.8.7 compatibility 102 | 103 | *3.2.6* (June 21, 2017) 104 | 105 | * Mention Rails 5 support 106 | 107 | *3.2.5* (Feb 26, 2015) 108 | 109 | * Support Ruby 2.2 GC.stat naming, avoiding 2.1 warnings 110 | 111 | *3.2.4* (July 25, 2014) 112 | 113 | * Fix compatibility with Rails 3.x tagged logging - @calavera 114 | 115 | *3.2.3* (June 23, 2014) 116 | 117 | * Report CPU/Idle time in tenths of a percent 118 | 119 | *3.2.2* (March 31, 2014) 120 | 121 | * Reduce default sampling rates. 122 | * Stop gauging all GC::Profiler data. Too noisy. 123 | * Report gauge readings as StatsD timings. 124 | * Support providing a Statsd::Batch since using Statsd#batch 125 | results in underfilled packets at low sample rates. 126 | * Fix bug with sending arrays of timings to StatsD. 127 | * Record GC timings in milliseconds. 128 | 129 | *3.1.0* (March 30, 2014) 130 | 131 | * Report percent CPU/idle time: Time.pct.cpu and Time.pct.idle. 132 | * Measure out-of-band GC count, time, and stats. Only meaningful for 133 | single-threaded servers like Unicorn. But then again so is per-request 134 | GC monitoring. 135 | * Support @tmm1's GC::OOB (https://github.com/tmm1/gctools). 136 | * Measure time between GCs. 137 | * Spiff up logger reports with more timings. 138 | * Support Rails log tags on logged reports. 139 | * Allow instruments' #start to set timings/gauges. 140 | 141 | *3.0.1* (March 30, 2014) 142 | 143 | * Sample requests to instrument based on StatsD sample rate. 144 | 145 | *3.0.0* (March 29, 2014) 146 | 147 | * Support new Ruby 2.0 and 2.1 GC stats. 148 | * Gauge GC details with GC::Profiler. 149 | * Performance rework. Faster, fewer allocations. 150 | * Rework counters and gauges as instruments. 151 | * Batch StatsD messages to decrease overhead on the server. 152 | * Drop NewRelic samplers. 153 | 154 | *2.0.5* (December 15, 2012) 155 | 156 | * Relax outdated statsd-ruby dependency. 157 | 158 | *2.0.0* (December 1, 2011) 159 | 160 | * Rails 3 support. 161 | * NewRelic samplers. 162 | 163 | *1.0.0* (August 24, 2009) 164 | 165 | * Initial release. 166 | --------------------------------------------------------------------------------