├── lib ├── time_bandits │ ├── version.rb │ ├── monkey_patches │ │ ├── active_record.rb │ │ ├── redis.rb │ │ ├── active_record │ │ │ ├── railties │ │ │ │ └── controller_runtime.rb │ │ │ ├── runtime_registry.rb │ │ │ └── log_subscriber.rb │ │ ├── memcache-client.rb │ │ ├── sequel.rb │ │ ├── memcached.rb │ │ └── action_controller.rb │ ├── time_consumers │ │ ├── sequel.rb │ │ ├── mem_cache.rb │ │ ├── beetle.rb │ │ ├── memcached.rb │ │ ├── dalli.rb │ │ ├── database.rb │ │ ├── redis.rb │ │ ├── base_consumer.rb │ │ ├── jmx.rb │ │ └── garbage_collection.rb │ ├── railtie.rb │ └── rack │ │ └── logger.rb └── time_bandits.rb ├── .gitignore ├── TODO ├── Gemfile ├── Appraisals ├── docker-compose.yml ├── Rakefile ├── test ├── test_helper.rb └── unit │ ├── sequel_test.rb │ ├── beetle_test.rb │ ├── duplicate_bandits.rb │ ├── dalli_test.rb │ ├── memcached_test.rb │ ├── redis_test.rb │ ├── active_support_notifications_test.rb │ ├── base_test.rb │ ├── gc_consumer_test.rb │ └── database_test.rb ├── LICENSE.txt ├── time_bandits.gemspec ├── .github └── workflows │ └── run-tests.yml ├── README.md └── CHANGELOG.md /lib/time_bandits/version.rb: -------------------------------------------------------------------------------- 1 | module TimeBandits 2 | VERSION = "0.15.2" 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | rdoc 2 | Gemfile.lock 3 | pkg/* 4 | *.gem 5 | .bundle 6 | .DS_Store 7 | .rvmrc 8 | dump.rdb 9 | gemfiles/* 10 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * make memcache consumers thread safe 2 | * test memcache consumers against a rails 3 app 3 | * maybe use ActiveSupport::Notifications everywhere? 4 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/active_record.rb: -------------------------------------------------------------------------------- 1 | if Gem::Version.new(ActiveRecord::VERSION::STRING) < Gem::Version.new("7.1.0") 2 | require_relative "active_record/log_subscriber" 3 | else 4 | require_relative "active_record/runtime_registry" 5 | end 6 | require_relative "active_record/railties/controller_runtime" 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in time_bandits.gemspec 4 | gemspec 5 | 6 | gem "ansi" 7 | gem "appraisal" 8 | gem "byebug" 9 | gem "dalli" 10 | gem "memcached", "~> 1.8.0" if RUBY_VERSION < "3.3.0" 11 | gem "minitest" 12 | gem "minitest-reporters" 13 | gem "mocha" 14 | gem "mysql2" 15 | gem "rake" 16 | gem "redis" 17 | gem "sequel" 18 | gem "activerecord" 19 | gem "beetle", ">= 3.4.1" 20 | gem "ostruct" 21 | gem "logger" 22 | 23 | gem "hiredis-client" 24 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraisals = [ 2 | "6.1.7.10", 3 | "7.0.8.7", 4 | "7.1.5.1", 5 | ] 6 | 7 | if RUBY_VERSION >= "3.1" 8 | appraisals << "7.2.2.1" 9 | end 10 | 11 | if RUBY_VERSION >= "3.2" 12 | appraisals << "8.0.2" 13 | end 14 | 15 | appraisals.each do |rails_version| 16 | %w(4.0 5.0).each do |redis_version| 17 | appraise "activesupport-#{rails_version}-redis-#{redis_version}" do 18 | gem "redis", "~> #{redis_version}" 19 | gem "activesupport", rails_version 20 | gem "activerecord", rails_version 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mysql: 3 | container_name: mysql 4 | image: mysql:8 5 | ports: 6 | - "3601:3306" 7 | environment: 8 | MYSQL_ALLOW_EMPTY_PASSWORD: 1 9 | 10 | memcached: 11 | container_name: memcached 12 | image: memcached:1.6.8 13 | ports: 14 | - "11211:11211" 15 | 16 | redis: 17 | container_name: redis 18 | image: redis:6.0 19 | ports: 20 | - "6379:6379" 21 | 22 | rabbitmq: 23 | container_name: rabbitmq 24 | image: rabbitmq:3.8 25 | ports: 26 | - "5672:5672" 27 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rake/testtask' 5 | include Rake::DSL 6 | 7 | $:.unshift 'lib' 8 | require 'time_bandits' 9 | 10 | task :default => :test 11 | 12 | Rake::TestTask.new do |t| 13 | t.libs << "test" 14 | t.test_files = FileList['test/**/*_test.rb'] 15 | t.verbose = true 16 | t.ruby_opts = %w(-W0) 17 | end 18 | 19 | namespace :appraisal do 20 | task :install do 21 | abort unless system("appraisal install") 22 | end 23 | task :test => :install do 24 | abort unless system("appraisal rake test") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/sequel.rb: -------------------------------------------------------------------------------- 1 | # a time consumer implementation for sequel 2 | # install into application_controller.rb with the line 3 | # 4 | # time_bandit TimeBandits::TimeConsumers::Sequel 5 | # 6 | require 'time_bandits/monkey_patches/sequel' 7 | 8 | module TimeBandits 9 | module TimeConsumers 10 | class Sequel < BaseConsumer 11 | prefix :db 12 | fields :time, :calls 13 | format "Sequel: %.3fms(%dq)", :time, :calls 14 | 15 | class Subscriber < ActiveSupport::LogSubscriber 16 | def duration(event) 17 | i = Sequel.instance 18 | i.time += (event.payload[:durationInSeconds] * 1000) 19 | i.calls += 1 20 | end 21 | end 22 | Subscriber.attach_to(:sequel) 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/mem_cache.rb: -------------------------------------------------------------------------------- 1 | # a time consumer implementation for memchache 2 | # install into application_controller.rb with the line 3 | # 4 | # time_bandit TimeBandits::TimeConsumers::MemCache 5 | # 6 | require 'time_bandits/monkey_patches/memcache-client' 7 | 8 | module TimeBandits 9 | module TimeConsumers 10 | class Memcache < BaseConsumer 11 | prefix :memcache 12 | fields :time, :calls, :misses 13 | format "MC: %.3f(%dr,%dm)", :time, :calls, :misses 14 | 15 | class Subscriber < ActiveSupport::LogSubscriber 16 | def get(event) 17 | i = Memcache.instance 18 | i.time += event.duration 19 | i.calls += 1 20 | i.misses += event.payload[:misses] 21 | end 22 | end 23 | Subscriber.attach_to :memcache 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/beetle.rb: -------------------------------------------------------------------------------- 1 | # a time consumer implementation for beetle publishing 2 | # install into application_controller.rb with the line 3 | # 4 | # time_bandit TimeBandits::TimeConsumers::Beetle 5 | # 6 | 7 | module TimeBandits 8 | module TimeConsumers 9 | class Beetle < BaseConsumer 10 | prefix :amqp 11 | fields :time, :calls 12 | format "Beetle: %.3f(%d)", :time, :calls 13 | 14 | class Subscriber < ActiveSupport::LogSubscriber 15 | def publish(event) 16 | 17 | i = Beetle.instance 18 | i.time += event.duration 19 | i.calls += 1 20 | 21 | return unless logger.debug? 22 | 23 | debug "%s (%.2fms)" % ["Beetle publish", event.duration] 24 | end 25 | end 26 | Subscriber.attach_to(:beetle) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/redis.rb: -------------------------------------------------------------------------------- 1 | require 'redis' 2 | 3 | if Redis::VERSION < "5.0" 4 | 5 | class Redis 6 | class Client 7 | alias :old_logging :logging 8 | 9 | def logging(commands, &block) 10 | ActiveSupport::Notifications.instrument('request.redis', commands: commands) do 11 | old_logging(commands, &block) 12 | end 13 | end 14 | end 15 | end 16 | 17 | else 18 | 19 | module TimeBandits 20 | module RedisInstrumentation 21 | def call(command, redis_config) 22 | ActiveSupport::Notifications.instrument("request.redis", commands: [command]) do 23 | super 24 | end 25 | end 26 | 27 | def call_pipelined(commands, redis_config) 28 | ActiveSupport::Notifications.instrument("request.redis", commands: commands) do 29 | super 30 | end 31 | end 32 | end 33 | end 34 | RedisClient.register(TimeBandits::RedisInstrumentation) 35 | 36 | end 37 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/active_record/railties/controller_runtime.rb: -------------------------------------------------------------------------------- 1 | require "active_record/railties/controller_runtime" 2 | 3 | module ActiveRecord 4 | module Railties 5 | module ControllerRuntime 6 | remove_method :cleanup_view_runtime 7 | def cleanup_view_runtime 8 | # this method has been redefined to do nothing for activerecord on purpose 9 | super 10 | end 11 | 12 | remove_method :append_info_to_payload 13 | def append_info_to_payload(payload) 14 | super 15 | if ActiveRecord::Base.connected? 16 | payload[:db_runtime] = TimeBandits::TimeConsumers::Database.instance.consumed 17 | end 18 | end 19 | 20 | module ClassMethods 21 | # this method has been redefined to do nothing for activerecord on purpose 22 | remove_method :log_process_action 23 | def log_process_action(payload) 24 | super 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest' 2 | require 'mocha/minitest' 3 | require 'minitest/autorun' 4 | 5 | require 'minitest/reporters' 6 | if ENV['MINITEST_REPORTER'] 7 | Minitest::Reporters.use! 8 | else 9 | Minitest::Reporters.use!([Minitest::Reporters::DefaultReporter.new]) 10 | end 11 | 12 | require 'active_support/testing/declarative' 13 | module Test 14 | module Unit 15 | class TestCase < Minitest::Test 16 | extend ActiveSupport::Testing::Declarative 17 | def assert_nothing_raised(*) 18 | yield 19 | end 20 | end 21 | end 22 | end 23 | 24 | require_relative '../lib/time_bandits' 25 | require "byebug" 26 | 27 | ActiveSupport::LogSubscriber.logger =::Logger.new("/dev/null") 28 | 29 | # fake Rails 30 | module Rails 31 | extend self 32 | ActiveSupport::Cache.format_version = 7.1 if Gem::Version.new(ActiveSupport::VERSION::STRING) >= Gem::Version.new("7.1.0") 33 | def cache 34 | @cache ||= ActiveSupport::Cache.lookup_store(:mem_cache_store) 35 | end 36 | def env 37 | "test" 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/memcache-client.rb: -------------------------------------------------------------------------------- 1 | # Add this line to your ApplicationController (app/controllers/application.rb) 2 | # to enable logging for memcache-client: 3 | # time_bandit MemCache 4 | 5 | require 'memcache' 6 | raise "MemCache needs to be loaded before monkey patching it" unless defined?(MemCache) 7 | 8 | class MemCache 9 | 10 | def get_with_benchmark(key, raw = false) 11 | ActiveSupport::Notifications.instrument("get.memcache") do |payload| 12 | val = get_without_benchmark(key, raw) 13 | payload[:misses] = val.nil? ? 1 : 0 14 | val 15 | end 16 | end 17 | alias_method :get_without_benchmark, :get 18 | alias_method :get, :get_with_benchmark 19 | 20 | def get_multi_with_benchmark(*keys) 21 | ActiveSupport::Notifications.instrument("get.memcache") do |payload| 22 | results = get_multi_without_benchmark(*keys) 23 | payload[:misses] = keys.size - results.size 24 | results 25 | end 26 | end 27 | alias_method :get_multi_without_benchmark, :get_multi 28 | alias_method :get_multi, :get_multi_with_benchmark 29 | 30 | end 31 | 32 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/memcached.rb: -------------------------------------------------------------------------------- 1 | # a time consumer implementation for memchached 2 | # install into application_controller.rb with the line 3 | # 4 | # time_bandit TimeBandits::TimeConsumers::Memcached 5 | # 6 | require 'time_bandits/monkey_patches/memcached' 7 | 8 | module TimeBandits 9 | module TimeConsumers 10 | class Memcached < BaseConsumer 11 | prefix :memcache 12 | fields :time, :calls, :misses, :reads, :writes 13 | format "MC: %.3f(%dr,%dm,%dw,%dc)", :time, :reads, :misses, :writes, :calls 14 | 15 | class Subscriber < ActiveSupport::LogSubscriber 16 | def get(event) 17 | i = Memcached.instance 18 | i.time += event.duration 19 | i.calls += 1 20 | payload = event.payload 21 | i.reads += payload[:reads] 22 | i.misses += payload[:misses] 23 | end 24 | def set(event) 25 | i = Memcached.instance 26 | i.time += event.duration 27 | i.calls += 1 28 | i.writes += 1 29 | end 30 | end 31 | Subscriber.attach_to :memcached 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2020 Stefan Kaes 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 | -------------------------------------------------------------------------------- /time_bandits.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "time_bandits/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "time_bandits" 7 | s.version = TimeBandits::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Stefan Kaes"] 10 | s.email = ["skaes@railsexpress.de"] 11 | s.homepage = "https://github.com/skaes/time_bandits/" 12 | s.summary = "Custom performance logging for Rails" 13 | s.description = "Rails Completed Line on Steroids" 14 | s.license = 'MIT' 15 | s.metadata = { 16 | "changelog_uri" => "https://github.com/skaes/time_bandits/blob/master/README.md#release-notes" 17 | } 18 | 19 | s.files = `git ls-files`.split("\n") 20 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 21 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 22 | s.require_paths = ["lib"] 23 | 24 | s.add_runtime_dependency("thread_variables") 25 | s.add_runtime_dependency("activesupport", [">= 5.2.0"]) 26 | s.add_runtime_dependency("base64") 27 | s.add_runtime_dependency("mutex_m") 28 | end 29 | 30 | -------------------------------------------------------------------------------- /test/unit/sequel_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | require 'sequel' 3 | 4 | class SequelTest < Test::Unit::TestCase 5 | def setup 6 | TimeBandits.time_bandits = [] 7 | TimeBandits.add TimeBandits::TimeConsumers::Sequel 8 | TimeBandits.reset 9 | end 10 | 11 | test "getting metrics" do 12 | nothing_measured = { 13 | :db_time => 0, 14 | :db_calls => 0 15 | } 16 | assert_equal nothing_measured, metrics 17 | assert_equal 0, TimeBandits.consumed 18 | assert_equal 0, TimeBandits.current_runtime 19 | end 20 | 21 | test "formatting" do 22 | bandit.calls = 3 23 | assert_equal "Sequel: 0.000ms(3q)", TimeBandits.runtime 24 | end 25 | 26 | test "metrics" do 27 | (1..4).each { sequel['SELECT 1'].all } 28 | 29 | assert_equal 6, metrics[:db_calls] # +2 for set wait_timeout and set SQL_AUTO_IS_NULL=0 30 | assert 0 < metrics[:db_time] 31 | assert_equal metrics[:db_time], TimeBandits.consumed 32 | end 33 | 34 | def mysql_port 35 | 3601 36 | end 37 | 38 | def sequel 39 | @sequel ||= Sequel.mysql2(host: "127.0.0.1", port: mysql_port, user: "root") 40 | end 41 | 42 | def metrics 43 | TimeBandits.metrics 44 | end 45 | 46 | def bandit 47 | TimeBandits::TimeConsumers::Sequel.instance 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/sequel.rb: -------------------------------------------------------------------------------- 1 | require 'sequel' 2 | 3 | major, minor, _ = Sequel.version.split('.').map(&:to_i) 4 | if major < 4 || (major == 4 && minor < 15) 5 | raise "time_bandits Sequel monkey patch is not compatible with your sequel version" 6 | end 7 | 8 | Sequel::Database.class_eval do 9 | if instance_methods.include?(:log_connection_yield) 10 | 11 | alias :_orig_log_connection_yield :log_connection_yield 12 | 13 | def log_connection_yield(*args, &block) 14 | begin 15 | start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 16 | _orig_log_connection_yield(*args, &block) 17 | ensure 18 | end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 19 | ActiveSupport::Notifications.instrument('duration.sequel', durationInSeconds: end_time - start_time) 20 | end 21 | end 22 | 23 | else 24 | 25 | alias :_orig_log_yield :log_yield 26 | 27 | def log_yield(*args, &block) 28 | begin 29 | start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 30 | _orig_log_yield(*args, &block) 31 | ensure 32 | end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 33 | ActiveSupport::Notifications.instrument('duration.sequel', durationInSeconds: end_time - start_time) 34 | end 35 | end 36 | 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/dalli.rb: -------------------------------------------------------------------------------- 1 | module TimeBandits::TimeConsumers 2 | class Dalli < BaseConsumer 3 | prefix :memcache 4 | fields :time, :calls, :misses, :reads, :writes 5 | format "Dalli: %.3f(%dr,%dm,%dw,%dc)", :time, :reads, :misses, :writes, :calls 6 | 7 | class Subscriber < ActiveSupport::LogSubscriber 8 | # cache events are: read write fetch_hit generate delete read_multi increment decrement clear 9 | def cache_read(event) 10 | i = cache(event) 11 | i.reads += 1 12 | i.misses += 1 unless event.payload[:hit] 13 | end 14 | 15 | def cache_read_multi(event) 16 | i = cache(event) 17 | i.reads += event.payload[:key].size 18 | end 19 | 20 | def cache_write(event) 21 | i = cache(event) 22 | i.writes += 1 23 | end 24 | 25 | def cache_increment(event) 26 | i = cache(event) 27 | i.writes += 1 28 | end 29 | 30 | def cache_decrement(event) 31 | i = cache(event) 32 | i.writes += 1 33 | end 34 | 35 | def cache_delete(event) 36 | i = cache(event) 37 | i.writes += 1 38 | end 39 | 40 | private 41 | def cache(event) 42 | i = Dalli.instance 43 | i.time += event.duration 44 | i.calls += 1 45 | i 46 | end 47 | end 48 | Subscriber.attach_to :active_support 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | strategy: 13 | matrix: 14 | include: 15 | - os: ubuntu-latest 16 | ruby-version: 3.4.5 17 | - os: ubuntu-latest 18 | ruby-version: 3.3.9 19 | - os: ubuntu-22.04 20 | ruby-version: 3.2.7 21 | - os: ubuntu-22.04 22 | ruby-version: 3.1.7 23 | 24 | runs-on: ${{ matrix.os }} 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Start containers 30 | run: docker compose -f docker-compose.yml up -d 31 | 32 | - name: Set up Ruby ${{ matrix.ruby-version }} 33 | uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: ${{ matrix.ruby-version }} 36 | 37 | - name: Install dependencies 38 | run: sudo apt-get install libsasl2-dev 39 | 40 | - name: Install gems 41 | run: bundle install 42 | 43 | - name: Run tests 44 | run: bundle exec rake test 45 | 46 | - name: Install appraisals 47 | run: bundle exec appraisal install 48 | 49 | - name: Run appraisals 50 | run: bundle exec appraisal rake test 51 | 52 | - name: Stop containers 53 | if: always() 54 | run: docker compose -f "docker-compose.yml" down 55 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/memcached.rb: -------------------------------------------------------------------------------- 1 | # Add this line to your ApplicationController (app/controllers/application_controller.rb) 2 | # to enable logging for memcached: 3 | # time_bandit TimeBandits::TimeConsumers::Memcached 4 | 5 | require 'memcached' 6 | raise "Memcached needs to be loaded before monkey patching it" unless defined?(Memcached) 7 | 8 | class Memcached 9 | def get_with_benchmark(key, marshal = true) 10 | ActiveSupport::Notifications.instrument("get.memcached") do |payload| 11 | if key.is_a?(Array) 12 | payload[:reads] = (num_keys = key.size) 13 | results = [] 14 | begin 15 | results = get_without_benchmark(key, marshal) 16 | rescue Memcached::NotFound 17 | end 18 | payload[:misses] = num_keys - results.size 19 | results 20 | else 21 | val = nil 22 | payload[:reads] = 1 23 | begin 24 | val = get_without_benchmark(key, marshal) 25 | rescue Memcached::NotFound 26 | end 27 | payload[:misses] = val.nil? ? 1 : 0 28 | val 29 | end 30 | end 31 | end 32 | alias_method :get_without_benchmark, :get 33 | alias_method :get, :get_with_benchmark 34 | 35 | def set_with_benchmark(*args) 36 | ActiveSupport::Notifications.instrument("set.memcached") do 37 | set_without_benchmark(*args) 38 | end 39 | end 40 | alias_method :set_without_benchmark, :set 41 | alias_method :set, :set_with_benchmark 42 | 43 | end 44 | -------------------------------------------------------------------------------- /test/unit/beetle_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | require 'beetle' 4 | 5 | class BeetleTest < Test::Unit::TestCase 6 | def setup 7 | TimeBandits.time_bandits = [] 8 | TimeBandits.add TimeBandits::TimeConsumers::Beetle 9 | TimeBandits.reset 10 | @beetle = Beetle::Client.new 11 | @beetle.configure do 12 | message :foo 13 | end 14 | @bandit = TimeBandits::TimeConsumers::Beetle.instance 15 | end 16 | 17 | test "getting metrics" do 18 | nothing_measured = { 19 | :amqp_time => 0, 20 | :amqp_calls => 0 21 | } 22 | assert_equal nothing_measured, TimeBandits.metrics 23 | assert_equal 0, TimeBandits.consumed 24 | assert_equal 0, TimeBandits.current_runtime 25 | end 26 | 27 | test "formatting" do 28 | @bandit.calls = 3 29 | assert_equal "Beetle: 0.000(3)", TimeBandits.runtime 30 | end 31 | 32 | test "foreground work gets accounted for" do 33 | work 34 | check_work 35 | end 36 | 37 | test "background work is ignored" do 38 | Thread.new do 39 | work 40 | check_work 41 | end.join 42 | m = TimeBandits.metrics 43 | assert_equal 0, m[:amqp_calls] 44 | assert_equal 0, m[:amqp_time] 45 | end 46 | 47 | private 48 | 49 | def work 50 | TimeBandits.reset 51 | 2.times do 52 | @beetle.publish("foo") 53 | @beetle.publish("foo") 54 | end 55 | end 56 | 57 | def check_work 58 | m = TimeBandits.metrics 59 | assert_equal 4, m[:amqp_calls] 60 | assert 0 < m[:amqp_time] 61 | assert_equal m[:amqp_time], TimeBandits.consumed 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/database.rb: -------------------------------------------------------------------------------- 1 | # this consumer gets installed automatically by the plugin 2 | # if this were not so 3 | # 4 | # TimeBandit.add TimeBandits::TimeConsumers::Database 5 | # 6 | # would do the job 7 | 8 | module TimeBandits 9 | module TimeConsumers 10 | # provide a time consumer interface to ActiveRecord 11 | class Database < TimeBandits::TimeConsumers::BaseConsumer 12 | prefix :db 13 | fields :time, :calls, :sql_query_cache_hits 14 | format "ActiveRecord: %.3fms(%dq,%dh)", :time, :calls, :sql_query_cache_hits 15 | 16 | class << self 17 | if Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.1.0") 18 | def metrics_store 19 | ActiveRecord::RuntimeRegistry 20 | end 21 | else 22 | def metrics_store 23 | ActiveRecord::LogSubscriber 24 | end 25 | end 26 | end 27 | 28 | def reset 29 | reset_stats 30 | super 31 | end 32 | 33 | def consumed 34 | time, calls, hits = reset_stats 35 | i = Database.instance 36 | i.sql_query_cache_hits += hits 37 | i.calls += calls 38 | i.time += time 39 | end 40 | 41 | def current_runtime 42 | Database.instance.time + self.class.metrics_store.runtime 43 | end 44 | 45 | private 46 | 47 | def reset_stats 48 | s = self.class.metrics_store 49 | hits = s.reset_query_cache_hits 50 | calls = s.reset_call_count 51 | time = s.reset_runtime 52 | [time, calls, hits] 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/unit/duplicate_bandits.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class DuplicateBandits < Test::Unit::TestCase 4 | class FooConsumer < TimeBandits::TimeConsumers::BaseConsumer 5 | prefix :simple 6 | fields :time, :calls 7 | format "SimpleFoo: %.1fms(%d calls)", :time, :calls 8 | end 9 | 10 | class BarConsumer < TimeBandits::TimeConsumers::BaseConsumer 11 | prefix :simple 12 | fields :time, :calls 13 | format "SimpleBar: %.1fms(%d calls)", :time, :calls 14 | end 15 | 16 | def setup 17 | TimeBandits.time_bandits = [] 18 | TimeBandits.add FooConsumer 19 | TimeBandits.add BarConsumer 20 | TimeBandits.reset 21 | end 22 | 23 | test "nothing measured" do 24 | assert_equal({ 25 | :simple_time => 0, 26 | :simple_calls => 0 27 | }, TimeBandits.metrics) 28 | end 29 | 30 | test "only one consumer measured sth (the one)" do 31 | FooConsumer.instance.calls = 3 32 | FooConsumer.instance.time = 0.123 33 | assert_equal({ 34 | :simple_time => 0.123, 35 | :simple_calls => 3 36 | }, TimeBandits.metrics) 37 | end 38 | 39 | test "only one consumer measured sth (the other)" do 40 | BarConsumer.instance.calls = 2 41 | BarConsumer.instance.time = 0.321 42 | assert_equal({ 43 | :simple_time => 0.321, 44 | :simple_calls => 2 45 | }, TimeBandits.metrics) 46 | end 47 | 48 | test "both consumer measured sth" do 49 | FooConsumer.instance.calls = 3 50 | FooConsumer.instance.time = 0.123 51 | BarConsumer.instance.calls = 2 52 | BarConsumer.instance.time = 0.321 53 | assert_equal({ 54 | :simple_time => 0.444, 55 | :simple_calls => 5 56 | }, TimeBandits.metrics) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/redis.rb: -------------------------------------------------------------------------------- 1 | # a time consumer implementation for redis 2 | # install into application_controller.rb with the line 3 | # 4 | # time_bandit TimeBandits::TimeConsumers::Redis 5 | # 6 | require 'time_bandits/monkey_patches/redis' 7 | 8 | module TimeBandits 9 | module TimeConsumers 10 | class Redis < BaseConsumer 11 | prefix :redis 12 | fields :time, :calls 13 | format "Redis: %.3f(%d)", :time, :calls 14 | 15 | class Subscriber < ActiveSupport::LogSubscriber 16 | def request(event) 17 | i = Redis.instance 18 | i.time += event.duration 19 | i.calls += 1 # count redis round trips, not calls 20 | 21 | return unless logger.debug? 22 | 23 | name = "%s (%.2fms)" % ["Redis", event.duration] 24 | cmds = event.payload[:commands] 25 | 26 | # output = " #{color(name, CYAN, true)}" 27 | output = " #{name}" 28 | 29 | cmds.each do |cmd, *args| 30 | if args.present? 31 | logged_args = args.map do |a| 32 | case 33 | when a.respond_to?(:inspect) then a.inspect 34 | when a.respond_to?(:to_s) then a.to_s 35 | else 36 | # handle poorly-behaved descendants of BasicObject 37 | klass = a.instance_exec { (class << self; self end).superclass } 38 | "\#<#{klass}:#{a.__id__}>" 39 | end 40 | end 41 | 42 | output << " [ #{cmd.to_s.upcase} #{logged_args.join(" ")} ]" 43 | else 44 | output << " [ #{cmd.to_s.upcase} ]" 45 | end 46 | end 47 | 48 | debug output 49 | end 50 | end 51 | Subscriber.attach_to(:redis) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/action_controller.rb: -------------------------------------------------------------------------------- 1 | module ActionController #:nodoc: 2 | 3 | require 'action_controller/metal/instrumentation' 4 | 5 | module Instrumentation 6 | 7 | def cleanup_view_runtime #:nodoc: 8 | consumed_before_rendering = TimeBandits.consumed 9 | runtime = yield 10 | consumed_during_rendering = TimeBandits.consumed - consumed_before_rendering 11 | runtime - consumed_during_rendering 12 | end 13 | 14 | private 15 | 16 | module ClassMethods 17 | # patch to log rendering time with more precision 18 | def log_process_action(payload) #:nodoc: 19 | messages, view_runtime = [], payload[:view_runtime] 20 | messages << ("Views: %.3fms" % view_runtime.to_f) if view_runtime 21 | messages 22 | end 23 | end 24 | end 25 | 26 | require 'action_controller/log_subscriber' 27 | 28 | class LogSubscriber 29 | # the original method logs the completed line. 30 | # but we do it in the middleware, unless we're in test mode. don't ask. 31 | def process_action(event) 32 | payload = event.payload 33 | additions = ActionController::Base.log_process_action(payload) 34 | 35 | Thread.current.thread_variable_set( 36 | :time_bandits_completed_info, 37 | [ event.duration, additions, payload[:view_runtime], "#{payload[:controller]}##{payload[:action]}" ] 38 | ) 39 | end 40 | end 41 | 42 | # this gets included in ActionController::Base in the time_bandits railtie 43 | module TimeBanditry #:nodoc: 44 | extend ActiveSupport::Concern 45 | 46 | module ClassMethods 47 | def log_process_action(payload) #:nodoc: 48 | # need to call this to compute DB time/calls 49 | TimeBandits.consumed 50 | super.concat(TimeBandits.runtimes) 51 | end 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/unit/dalli_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class DalliTest < Test::Unit::TestCase 4 | def setup 5 | TimeBandits.time_bandits = [] 6 | TimeBandits.add TimeBandits::TimeConsumers::Dalli 7 | TimeBandits.reset 8 | @cache = Rails.cache 9 | @bandit = TimeBandits::TimeConsumers::Dalli.instance 10 | end 11 | 12 | test "getting metrics" do 13 | nothing_measured = { 14 | :memcache_time => 0, 15 | :memcache_calls => 0, 16 | :memcache_misses => 0, 17 | :memcache_reads => 0, 18 | :memcache_writes => 0 19 | } 20 | assert_equal nothing_measured, TimeBandits.metrics 21 | assert_equal 0, TimeBandits.consumed 22 | assert_equal 0, TimeBandits.current_runtime 23 | end 24 | 25 | test "formatting" do 26 | @bandit.calls = 3 27 | assert_equal "Dalli: 0.000(0r,0m,0w,3c)", TimeBandits.runtime 28 | end 29 | 30 | test "foreground work gets accounted for" do 31 | work 32 | check_work 33 | end 34 | 35 | test "background work is ignored" do 36 | Thread.new do 37 | work 38 | check_work 39 | end.join 40 | m = TimeBandits.metrics 41 | assert_equal 0, m[:memcache_calls] 42 | assert_equal 0, m[:memcache_reads] 43 | assert_equal 0, m[:memcache_misses] 44 | assert_equal 0, m[:memcache_writes] 45 | assert_equal 0, m[:memcache_time] 46 | end 47 | 48 | private 49 | def work 50 | TimeBandits.reset 51 | 2.times do 52 | @cache.read("foo") 53 | @cache.write("bar", 1) 54 | end 55 | end 56 | def check_work 57 | m = TimeBandits.metrics 58 | assert_equal 4, m[:memcache_calls] 59 | assert_equal 2, m[:memcache_reads] 60 | assert_equal 2, m[:memcache_misses] 61 | assert_equal 2, m[:memcache_writes] 62 | assert 0 < m[:memcache_time] 63 | assert_equal m[:memcache_time], TimeBandits.consumed 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/base_consumer.rb: -------------------------------------------------------------------------------- 1 | module TimeBandits::TimeConsumers 2 | class BaseConsumer 3 | class << self 4 | def instance 5 | Thread.current.thread_variable_get(key) || 6 | Thread.current.thread_variable_set(key, new) 7 | end 8 | 9 | def key 10 | @key ||= name.to_sym 11 | end 12 | 13 | def prefix(sym) 14 | @metrics_prefix = sym 15 | end 16 | 17 | # first symbol is used as time measurement 18 | def fields(*symbols) 19 | @struct = Struct.new(*(symbols.map{|s| "#{@metrics_prefix}_#{s}".to_sym})) 20 | symbols.each do |name| 21 | class_eval(<<-"EVA", __FILE__, __LINE__ + 1) 22 | def #{name}; @counters.#{@metrics_prefix}_#{name}; end 23 | def #{name}=(v); @counters.#{@metrics_prefix}_#{name} = v; end 24 | EVA 25 | end 26 | end 27 | 28 | def format(f, *keys) 29 | @runtime_format = f 30 | @runtime_keys = keys.map{|s| "#{@metrics_prefix}_#{s}".to_sym} 31 | end 32 | 33 | attr_reader :metrics_prefix, :struct, :timer_name, :runtime_format, :runtime_keys 34 | 35 | def method_missing(m, *args) 36 | (i = instance).respond_to?(m) ? i.send(m,*args) : super 37 | end 38 | end 39 | 40 | def initialize 41 | @counters = self.class.struct.new 42 | reset 43 | end 44 | 45 | def reset 46 | @counters.length.times{|i| @counters[i] = 0} 47 | end 48 | 49 | def metrics 50 | @counters.members.each_with_object({}){|m,h| h[m] = @counters.send(m)} 51 | end 52 | 53 | def consumed 54 | @counters[0] 55 | end 56 | 57 | alias_method :current_runtime, :consumed 58 | 59 | def runtime 60 | values = metrics.values_at(*self.class.runtime_keys) 61 | if values.all?{|v|v==0} 62 | "" 63 | else 64 | self.class.runtime_format % values 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/unit/memcached_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class MemcachedTest < Test::Unit::TestCase 4 | def setup 5 | skip "memcached is currently not supported on Ruby #{RUBY_VERSION} as the gem does not compile" unless RUBY_VERSION < "3.3.0" 6 | TimeBandits.time_bandits = [] 7 | TimeBandits.add TimeBandits::TimeConsumers::Memcached 8 | TimeBandits.reset 9 | @cache = Memcached.new 10 | @bandit = TimeBandits::TimeConsumers::Memcached.instance 11 | end 12 | 13 | test "getting metrics" do 14 | nothing_measured = { 15 | :memcache_time => 0, 16 | :memcache_calls => 0, 17 | :memcache_misses => 0, 18 | :memcache_reads => 0, 19 | :memcache_writes => 0 20 | } 21 | assert_equal nothing_measured, TimeBandits.metrics 22 | assert_equal 0, TimeBandits.consumed 23 | assert_equal 0, TimeBandits.current_runtime 24 | end 25 | 26 | test "formatting" do 27 | @bandit.calls = 3 28 | assert_equal "MC: 0.000(0r,0m,0w,3c)", TimeBandits.runtime 29 | end 30 | 31 | test "foreground work gets accounted for" do 32 | work 33 | check_work 34 | end 35 | 36 | test "background work is ignored" do 37 | Thread.new do 38 | work 39 | check_work 40 | end.join 41 | m = TimeBandits.metrics 42 | assert_equal 0, m[:memcache_calls] 43 | assert_equal 0, m[:memcache_reads] 44 | assert_equal 0, m[:memcache_misses] 45 | assert_equal 0, m[:memcache_writes] 46 | assert_equal 0, m[:memcache_time] 47 | end 48 | 49 | private 50 | def work 51 | 2.times do 52 | @cache.get("foo") 53 | @cache.set("bar", 1) 54 | end 55 | end 56 | def check_work 57 | m = TimeBandits.metrics 58 | assert_equal 4, m[:memcache_calls] 59 | assert_equal 2, m[:memcache_reads] 60 | assert_equal 2, m[:memcache_misses] 61 | assert_equal 2, m[:memcache_writes] 62 | assert 0 < m[:memcache_time] 63 | assert_equal m[:memcache_time], TimeBandits.consumed 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/unit/redis_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class RedisTest < Test::Unit::TestCase 4 | def setup 5 | TimeBandits.time_bandits = [] 6 | TimeBandits.add TimeBandits::TimeConsumers::Redis 7 | TimeBandits.reset 8 | @redis = Redis.new 9 | @bandit = TimeBandits::TimeConsumers::Redis.instance 10 | end 11 | 12 | test "getting metrics" do 13 | nothing_measured = { 14 | :redis_time => 0, 15 | :redis_calls => 0 16 | } 17 | assert_equal nothing_measured, TimeBandits.metrics 18 | assert_equal 0, TimeBandits.consumed 19 | assert_equal 0, TimeBandits.current_runtime 20 | end 21 | 22 | test "formatting" do 23 | @bandit.calls = 3 24 | assert_equal "Redis: 0.000(3)", TimeBandits.runtime 25 | end 26 | 27 | test "foreground work gets accounted for" do 28 | work 29 | check_work 30 | end 31 | 32 | test "background work is ignored" do 33 | Thread.new do 34 | work 35 | check_work 36 | end.join 37 | m = TimeBandits.metrics 38 | assert_equal 0, m[:redis_calls] 39 | assert_equal 0, m[:redis_time] 40 | end 41 | 42 | test "counts pipelined calls as single call" do 43 | pipelined_work 44 | m = TimeBandits.metrics 45 | assert_equal 1, m[:redis_calls] 46 | end 47 | 48 | test "counts multi calls as single call" do 49 | pipelined_work(:multi) 50 | m = TimeBandits.metrics 51 | assert_equal 1, m[:redis_calls] 52 | end 53 | 54 | private 55 | def pipelined_work(type = :pipelined) 56 | TimeBandits.reset 57 | @redis.send(type) do |transaction| 58 | transaction.get("foo") 59 | transaction.set("bar", 1) 60 | transaction.hgetall("baz") 61 | end 62 | end 63 | 64 | def work 65 | TimeBandits.reset 66 | 2.times do 67 | @redis.get("foo") 68 | @redis.set("bar", 1) 69 | end 70 | end 71 | def check_work 72 | m = TimeBandits.metrics 73 | assert_equal 4, m[:redis_calls] 74 | assert 0 < m[:redis_time] 75 | assert_equal m[:redis_time], TimeBandits.consumed 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/unit/active_support_notifications_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | require 'active_support/notifications' 3 | require 'active_support/log_subscriber' 4 | require 'thread_variables/access' 5 | 6 | class ActiveSupportNotificationsTest < Test::Unit::TestCase 7 | class SimpleConsumer < TimeBandits::TimeConsumers::BaseConsumer 8 | prefix :simple 9 | fields :time, :calls 10 | format "Simple: %.1fms(%d calls)", :time, :calls 11 | 12 | class Subscriber < ActiveSupport::LogSubscriber 13 | def work(event) 14 | i = SimpleConsumer.instance 15 | i.time += event.duration 16 | i.calls += 1 17 | end 18 | end 19 | Subscriber.attach_to :simple 20 | end 21 | 22 | def setup 23 | TimeBandits.time_bandits = [] 24 | TimeBandits.add SimpleConsumer 25 | TimeBandits.reset 26 | @bandit = SimpleConsumer.instance 27 | end 28 | 29 | test "getting metrics" do 30 | assert_equal({:simple_calls => 0, :simple_time => 0}, TimeBandits.metrics) 31 | assert_equal 0, TimeBandits.consumed 32 | assert_equal 0, TimeBandits.current_runtime 33 | end 34 | 35 | test "formatting" do 36 | assert_same @bandit, TimeBandits.time_bandits.first.instance 37 | @bandit.calls = 1 38 | assert_equal "Simple: 0.0ms(1 calls)", TimeBandits.runtime 39 | end 40 | 41 | test "foreground work gets accounted for in milliseconds" do 42 | work 43 | check_work 44 | end 45 | 46 | test "background work is ignored" do 47 | Thread.new do 48 | work 49 | check_work 50 | end.join 51 | m = TimeBandits.metrics 52 | assert_equal 0, m[:simple_calls] 53 | assert_equal 0, m[:simple_time] 54 | end 55 | 56 | private 57 | def work 58 | 2.times do 59 | ActiveSupport::Notifications.instrument("work.simple") { sleep 0.1 } 60 | end 61 | end 62 | def check_work 63 | m = TimeBandits.metrics 64 | assert_equal 2, m[:simple_calls] 65 | assert 200 < m[:simple_time] 66 | assert 300 > m[:simple_time] 67 | assert_equal m[:simple_time], TimeBandits.consumed 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/unit/base_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class NoTimeBanditsTest < Test::Unit::TestCase 4 | def setup 5 | TimeBandits.time_bandits = [] 6 | end 7 | 8 | test "getting list of all time bandits" do 9 | assert_equal [], TimeBandits.time_bandits 10 | test_clean_state 11 | end 12 | 13 | test "reset" do 14 | assert_nothing_raised { TimeBandits.reset } 15 | test_clean_state 16 | end 17 | 18 | test "benchmarking" do 19 | logger = mock("logger") 20 | logger.expects(:info) 21 | TimeBandits.benchmark("foo", logger) { } 22 | test_clean_state 23 | end 24 | 25 | private 26 | def test_clean_state 27 | assert_equal Hash.new, TimeBandits.metrics 28 | assert_equal 0, TimeBandits.consumed 29 | assert_equal 0, TimeBandits.current_runtime 30 | assert_equal "", TimeBandits.runtime 31 | end 32 | end 33 | 34 | class DummyConsumerTest < Test::Unit::TestCase 35 | module DummyConsumer 36 | extend self 37 | def consumed; 1; end 38 | def current_runtime; 1; end 39 | def runtime; "Dummy: 0ms"; end 40 | def metrics; {:dummy_time => 1, :dummy_calls => 1}; end 41 | def reset; end 42 | end 43 | 44 | def setup 45 | TimeBandits.time_bandits = [] 46 | TimeBandits.add DummyConsumer 47 | end 48 | 49 | test "getting list of all time bandits" do 50 | assert_equal [DummyConsumer], TimeBandits.time_bandits 51 | end 52 | 53 | test "adding consumer a second time does not change the list of time bandits" do 54 | TimeBandits.add DummyConsumer 55 | assert_equal [DummyConsumer], TimeBandits.time_bandits 56 | end 57 | 58 | test "reset" do 59 | assert_nothing_raised { TimeBandits.reset } 60 | end 61 | 62 | test "consumed" do 63 | assert_equal 1, TimeBandits.consumed 64 | end 65 | 66 | test "current_runtime" do 67 | assert_equal 1, TimeBandits.current_runtime 68 | end 69 | 70 | test "current_runtime without DummyConsumer" do 71 | assert_equal 0, TimeBandits.current_runtime(DummyConsumer) 72 | end 73 | 74 | test "getting metrics" do 75 | assert_equal({:dummy_time => 1, :dummy_calls => 1}, TimeBandits.metrics) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/jmx.rb: -------------------------------------------------------------------------------- 1 | # a time consumer implementation for jruby, using jmx 2 | # 3 | # the gc counts and times reported are summed over all the garbage collectors 4 | # heap_growth reflects changes in the committed size of the java heap 5 | # heap_size is the committed size of the java heap 6 | # allocated_size reflects changes in the active (used) part of the java heap 7 | # java non-heap memory is not reported 8 | 9 | require 'jmx' if defined? JRUBY_VERSION 10 | 11 | module TimeBandits 12 | module TimeConsumers 13 | class JMX 14 | def initialize 15 | @server = ::JMX::MBeanServer.new 16 | @memory_bean = @server["java.lang:type=Memory"] 17 | @collectors = @server.query_names "java.lang:type=GarbageCollector,*" 18 | reset 19 | end 20 | private :initialize 21 | 22 | def self.instance 23 | @instance ||= new 24 | end 25 | 26 | def consumed 27 | 0.0 28 | end 29 | 30 | def gc_time 31 | @collectors.to_array.map {|gc| @server[gc].collection_time}.sum 32 | end 33 | 34 | def gc_collections 35 | @collectors.to_array.map {|gc| @server[gc].collection_count}.sum 36 | end 37 | 38 | def heap_size 39 | @memory_bean.heap_memory_usage.committed 40 | end 41 | 42 | def heap_usage 43 | @memory_bean.heap_memory_usage.used 44 | end 45 | 46 | def reset 47 | @consumed = gc_time 48 | @collections = gc_collections 49 | @heap_committed = heap_size 50 | @heap_used = heap_usage 51 | end 52 | 53 | def collections_delta 54 | gc_collections - @collections 55 | end 56 | 57 | def gc_time_delta 58 | (gc_time - @consumed).to_f 59 | end 60 | 61 | def heap_growth 62 | heap_size - @heap_committed 63 | end 64 | 65 | def usage_growth 66 | heap_usage - @heap_used 67 | end 68 | 69 | def allocated_objects 70 | 0 71 | end 72 | 73 | def runtime 74 | "GC: %.3f(%d), HP: %d(%d,%d,%d)" % [gc_time_delta, collections_delta, heap_growth, heap_size, allocated_objects, usage_growth] 75 | end 76 | 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/unit/gc_consumer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | 3 | class GCConsumerTest < Test::Unit::TestCase 4 | def setup 5 | TimeBandits.time_bandits = [] 6 | TimeBandits.add TimeBandits::TimeConsumers::GarbageCollection.instance 7 | TimeBandits.reset 8 | end 9 | 10 | test "getting metrics" do 11 | # example metrics hash: 12 | sample = { 13 | :gc_time => 0.5, 14 | :gc_calls => 0, 15 | :heap_growth => 0, 16 | :heap_size => 116103, 17 | :allocated_objects => 8, 18 | :allocated_bytes => 152, 19 | :live_data_set_size => 69437 20 | } 21 | m = TimeBandits.metrics 22 | assert_equal sample.keys.sort, m.keys.sort 23 | assert_equal 0, TimeBandits.consumed 24 | assert_equal 0, TimeBandits.current_runtime 25 | end 26 | 27 | test "formatting" do 28 | # example runtime: 29 | # "GC: 0.000(0) | HP: 0(116101,6,0,69442)" 30 | gc, heap = TimeBandits.runtime.split(' | ') 31 | assert_equal "GC: 0.000(0)", gc 32 | match = /\AHP: \d+\(\d+,\d+,\d+,\d+\)/ 33 | assert(heap =~ match, "#{heap} does not match #{match}") 34 | end 35 | 36 | test "collecting GC stats" do 37 | work 38 | check_work 39 | end 40 | 41 | private 42 | def work 43 | TimeBandits.reset 44 | a = [] 45 | 10.times do |i| 46 | a << (i.to_s * 100) 47 | end 48 | end 49 | def check_work 50 | GC.start 51 | m = TimeBandits.metrics 52 | if GC.respond_to?(:time) 53 | assert_operator 0, :<, m[:gc_calls] 54 | assert_operator 0, :<, m[:gc_time] 55 | assert_instance_of Integer, m[:heap_growth] 56 | assert_operator 0, :<, m[:heap_size] 57 | assert_operator 0, :<, m[:allocated_objects] 58 | assert_operator 0, :<, m[:allocated_bytes] 59 | assert_operator 0, :<=, m[:live_data_set_size] 60 | else 61 | assert_operator 0, :<, m[:gc_calls] 62 | assert_operator 0, :<=, m[:gc_time] 63 | assert_instance_of Integer, m[:heap_growth] 64 | assert_operator 0, :<, m[:heap_size] 65 | assert_operator 0, :<, m[:allocated_objects] 66 | assert_operator 0, :<=, m[:allocated_bytes] 67 | assert_operator 0, :<, m[:live_data_set_size] 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/active_record/runtime_registry.rb: -------------------------------------------------------------------------------- 1 | # This file monkey patches class ActiveRecord::RuntimeRegistry to 2 | # additionally store call counts and cache hits and subscribes an 3 | # event listener to manage those counters. It is used if the active 4 | # record version is 7.1.0 or higher. 5 | 6 | require "active_record/runtime_registry" 7 | 8 | module ActiveRecord 9 | module RuntimeRegistry 10 | 11 | if respond_to?(:queries_count) 12 | alias_method :call_count, :queries_count 13 | alias_method :call_count=, :queries_count= 14 | else 15 | def self.call_count 16 | ActiveSupport::IsolatedExecutionState[:active_record_sql_call_count] ||= 0 17 | end 18 | 19 | def self.call_count=(value) 20 | ActiveSupport::IsolatedExecutionState[:active_record_sql_call_count] = value 21 | end 22 | end 23 | 24 | if respond_to?(:cached_queries_count) 25 | alias_method :query_cache_hits, :cached_queries_count 26 | alias_method :query_cache_hits=, :cached_queries_count= 27 | else 28 | def self.query_cache_hits 29 | ActiveSupport::IsolatedExecutionState[:active_record_sql_query_cache_hits] ||= 0 30 | end 31 | 32 | def self.query_cache_hits=(value) 33 | ActiveSupport::IsolatedExecutionState[:active_record_sql_query_cache_hits] = value 34 | end 35 | end 36 | 37 | if respond_to?(:reset_runtimes) 38 | alias_method :reset_runtime, :reset_runtimes 39 | else 40 | alias_method :reset_runtime, :reset 41 | end 42 | alias_method :runtime, :sql_runtime 43 | alias_method :runtime=, :sql_runtime= 44 | 45 | def self.reset_call_count 46 | calls = call_count 47 | self.call_count = 0 48 | calls 49 | end 50 | 51 | def self.reset_query_cache_hits 52 | hits = query_cache_hits 53 | self.query_cache_hits = 0 54 | hits 55 | end 56 | 57 | end 58 | end 59 | 60 | 61 | # Rails 7.2 already collects query counts and cache hits, so we no 62 | # longer need our own event handler. 63 | unless ActiveRecord::RuntimeRegistry.respond_to?(:queries_count) 64 | require "active_support/notifications" 65 | 66 | ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |event| 67 | ActiveRecord::RuntimeRegistry.call_count += 1 68 | ActiveRecord::RuntimeRegistry.query_cache_hits += 1 if event.payload[:cached] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/time_bandits/railtie.rb: -------------------------------------------------------------------------------- 1 | module TimeBandits 2 | 3 | module Rack 4 | autoload :Logger, 'time_bandits/rack/logger' 5 | end 6 | 7 | class Railtie < Rails::Railtie 8 | 9 | initializer "time_bandits" do |app| 10 | app.config.middleware.insert_after(Rails::Rack::Logger, TimeBandits::Rack::Logger) 11 | app.config.middleware.delete(Rails::Rack::Logger) 12 | 13 | ActiveSupport.on_load(:action_controller) do 14 | require 'time_bandits/monkey_patches/action_controller' 15 | 16 | # Rails 5 may trigger the on_load event several times. 17 | next if included_modules.include?(ActionController::TimeBanditry) 18 | # For some magic reason, the test above is always false, but I'll leave it in 19 | # here, should the Rails team ever decide to change this behavior. 20 | 21 | include ActionController::TimeBanditry 22 | 23 | # make sure TimeBandits.reset is called in test environment as middlewares are not executed 24 | if Rails.env.test? 25 | require 'action_controller/test_case' 26 | # Rails 5 fires on_load events multiple times, so we need to protect against endless recursion here 27 | next if ActionController::TestCase::Behavior.instance_methods.include?(:process_without_time_bandits) 28 | module ActionController::TestCase::Behavior 29 | def process_with_time_bandits(action, **opts) 30 | TimeBandits.reset 31 | process_without_time_bandits(action, **opts) 32 | end 33 | alias_method :process_without_time_bandits, :process 34 | alias_method :process, :process_with_time_bandits 35 | end 36 | end 37 | end 38 | 39 | ActiveSupport.on_load(:active_record) do 40 | require 'time_bandits/monkey_patches/active_record' 41 | # TimeBandits.add is idempotent, so no need to protect against on_load fired multiple times. 42 | TimeBandits.add TimeBandits::TimeConsumers::Database 43 | end 44 | 45 | # Reset statistics info, so that for example the time for the first request handled 46 | # by the dispatcher is correct. Also: install GC time bandit here, as we want it to 47 | # be the last one in the log line. 48 | app.config.after_initialize do 49 | TimeBandits.add TimeBandits::TimeConsumers::GarbageCollection.instance if GC.respond_to? :enable_stats 50 | TimeBandits.reset 51 | end 52 | 53 | end 54 | 55 | end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /lib/time_bandits.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'active_support' 3 | require 'active_support/core_ext' 4 | require 'thread_variables' 5 | 6 | module TimeBandits 7 | 8 | module TimeConsumers 9 | autoload :Database, 'time_bandits/time_consumers/database' 10 | autoload :GarbageCollection, 'time_bandits/time_consumers/garbage_collection' 11 | autoload :JMX, 'time_bandits/time_consumers/jmx' 12 | autoload :MemCache, 'time_bandits/time_consumers/mem_cache' 13 | autoload :Memcached, 'time_bandits/time_consumers/memcached' 14 | autoload :Dalli, 'time_bandits/time_consumers/dalli' 15 | autoload :Redis, 'time_bandits/time_consumers/redis' 16 | autoload :Sequel, 'time_bandits/time_consumers/sequel' 17 | autoload :Beetle, 'time_bandits/time_consumers/beetle' 18 | end 19 | 20 | require 'time_bandits/railtie' if defined?(Rails::Railtie) 21 | require 'time_bandits/time_consumers/base_consumer' 22 | 23 | mattr_accessor :time_bandits 24 | self.time_bandits = [] 25 | 26 | def self.add(bandit) 27 | self.time_bandits << bandit unless self.time_bandits.include?(bandit) 28 | end 29 | 30 | def self.reset 31 | time_bandits.each{|b| b.reset} 32 | end 33 | 34 | def self.consumed 35 | time_bandits.map{|b| b.consumed}.sum 36 | end 37 | 38 | def self.current_runtime(except = []) 39 | except = Array(except) 40 | time_bandits.map{|b| except.include?(b) ? 0 : b.current_runtime}.sum 41 | end 42 | 43 | def self.runtimes 44 | time_bandits.map{|b| b.runtime}.reject{|t| t.blank?} 45 | end 46 | 47 | def self.runtime 48 | runtimes.join(" | ") 49 | end 50 | 51 | def self.metrics 52 | metrics = Hash.new(0) 53 | time_bandits.each do |bandit| 54 | bandit.metrics.each do |k,v| 55 | metrics[k] += v 56 | end 57 | end 58 | metrics 59 | end 60 | 61 | def self.benchmark(title="Completed in", logger=Rails.logger) 62 | reset 63 | result = nil 64 | e = nil 65 | seconds = Benchmark.realtime do 66 | begin 67 | result = yield 68 | rescue Exception => e 69 | logger.error "Exception: #{e.class}(#{e.message}):\n#{e.backtrace[0..5].join("\n")}" 70 | end 71 | end 72 | consumed # needs to be called for DB time consumer 73 | rc = e ? "500 Internal Server Error" : "200 OK" 74 | logger.info "#{title} #{sprintf("%.3f", seconds * 1000)}ms (#{runtime}) | #{rc}" 75 | raise e if e 76 | result 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/time_bandits/monkey_patches/active_record/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # This file monkey patches class ActiveRecord::LogSubscriber to count 2 | # the number of sql statements being executed and the number of query 3 | # cache hits, but is only used for Rails versions before 7.1.0. 4 | 5 | require "active_record/log_subscriber" 6 | 7 | module ActiveRecord 8 | class LogSubscriber 9 | IGNORE_PAYLOAD_NAMES = ["SCHEMA", "EXPLAIN"] unless defined?(IGNORE_PAYLOAD_NAMES) 10 | 11 | def self.call_count=(value) 12 | Thread.current.thread_variable_set(:active_record_sql_call_count, value) 13 | end 14 | 15 | def self.call_count 16 | Thread.current.thread_variable_get(:active_record_sql_call_count) || 17 | Thread.current.thread_variable_set(:active_record_sql_call_count, 0) 18 | end 19 | 20 | def self.query_cache_hits=(value) 21 | Thread.current.thread_variable_set(:active_record_sql_query_cache_hits, value) 22 | end 23 | 24 | def self.query_cache_hits 25 | Thread.current.thread_variable_get(:active_record_sql_query_cache_hits) || 26 | Thread.current.thread_variable_set(:active_record_sql_query_cache_hits, 0) 27 | end 28 | 29 | def self.reset_call_count 30 | calls = call_count 31 | self.call_count = 0 32 | calls 33 | end 34 | 35 | def self.reset_query_cache_hits 36 | hits = query_cache_hits 37 | self.query_cache_hits = 0 38 | hits 39 | end 40 | 41 | remove_method :sql 42 | def sql(event) 43 | payload = event.payload 44 | 45 | self.class.runtime += event.duration 46 | self.class.call_count += 1 47 | self.class.query_cache_hits += 1 if payload[:cached] || payload[:name] == "CACHE" 48 | 49 | return unless logger.debug? 50 | 51 | return if IGNORE_PAYLOAD_NAMES.include?(payload[:name]) 52 | 53 | log_sql_statement(payload, event) 54 | end 55 | 56 | private 57 | def log_sql_statement(payload, event) 58 | name = "#{payload[:name]} (#{event.duration.round(1)}ms)" 59 | name = "CACHE #{name}" if payload[:cached] 60 | sql = payload[:sql] 61 | binds = nil 62 | 63 | unless (payload[:binds] || []).empty? 64 | casted_params = type_casted_binds(payload[:type_casted_binds]) 65 | binds = " " + payload[:binds].zip(casted_params).map { |attr, value| 66 | render_bind(attr, value) 67 | }.inspect 68 | end 69 | 70 | name = colorize_payload_name(name, payload[:name]) 71 | sql = color(sql, sql_color(sql), true) 72 | 73 | debug " #{name} #{sql}#{binds}" 74 | end 75 | end 76 | 77 | end 78 | -------------------------------------------------------------------------------- /lib/time_bandits/rack/logger.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/time/conversions' 2 | require 'active_support/core_ext/object/blank' 3 | require 'active_support/log_subscriber' 4 | require 'action_dispatch/http/request' 5 | require 'rack/body_proxy' 6 | 7 | module TimeBandits 8 | module Rack 9 | # Sets log tags, logs the request, calls the app, and flushes the logs. 10 | class Logger < ActiveSupport::LogSubscriber 11 | def initialize(app, taggers = nil) 12 | @app = app 13 | @taggers = taggers || Rails.application.config.log_tags || [] 14 | @instrumenter = ActiveSupport::Notifications.instrumenter 15 | @use_to_default_s = Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new("7.1.0") 16 | end 17 | 18 | def call(env) 19 | request = ActionDispatch::Request.new(env) 20 | 21 | if logger.respond_to?(:tagged) && !@taggers.empty? 22 | logger.tagged(compute_tags(request)) { call_app(request, env) } 23 | else 24 | call_app(request, env) 25 | end 26 | end 27 | 28 | protected 29 | 30 | def call_app(request, env) 31 | start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 32 | start(request, Time.now) 33 | resp = @app.call(env) 34 | resp[2] = ::Rack::BodyProxy.new(resp[2]) { finish(request) } 35 | resp 36 | rescue 37 | finish(request) 38 | raise 39 | ensure 40 | end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) 41 | completed(request, (end_time - start_time) * 1000, resp) 42 | ActiveSupport::LogSubscriber.flush_all! 43 | end 44 | 45 | # Started GET "/session/new" for 127.0.0.1 at 2012-09-26 14:51:42 -0700 46 | def started_request_message(request, start_time = Time.now) 47 | start_time_str = @use_to_default_s ? start_time.to_default_s : start_time.to_s 48 | 'Started %s "%s" for %s at %s' % [ 49 | request.request_method, 50 | request.filtered_path, 51 | request.ip, 52 | start_time_str ] 53 | end 54 | 55 | def compute_tags(request) 56 | @taggers.collect do |tag| 57 | case tag 58 | when Proc 59 | tag.call(request) 60 | when Symbol 61 | request.send(tag) 62 | else 63 | tag 64 | end 65 | end 66 | end 67 | 68 | private 69 | 70 | def start(request, start_time) 71 | TimeBandits.reset 72 | Thread.current.thread_variable_set(:time_bandits_completed_info, nil) 73 | @instrumenter.start 'action_dispatch.request', request: request 74 | 75 | logger.debug "" 76 | logger.info started_request_message(request, start_time) 77 | end 78 | 79 | def completed(request, run_time, resp) 80 | status = resp ? resp.first.to_i : 500 81 | completed_info = Thread.current.thread_variable_get(:time_bandits_completed_info) 82 | additions = completed_info[1] if completed_info 83 | message = "Completed #{status} #{::Rack::Utils::HTTP_STATUS_CODES[status]} in %.1fms" % run_time 84 | message << " (#{additions.join(' | ')})" unless additions.blank? 85 | logger.info message 86 | end 87 | 88 | def finish(request) 89 | @instrumenter.finish 'action_dispatch.request', request: request 90 | end 91 | 92 | def logger 93 | @logger ||= Rails.logger 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/unit/database_test.rb: -------------------------------------------------------------------------------- 1 | require_relative '../test_helper' 2 | require 'active_record' 3 | require 'time_bandits/monkey_patches/active_record' 4 | 5 | class DatabaseTest < Test::Unit::TestCase 6 | 7 | def setup 8 | TimeBandits.time_bandits = [] 9 | TimeBandits.add TimeBandits::TimeConsumers::Database 10 | TimeBandits.reset 11 | @old_logger = ActiveRecord::Base.logger 12 | ActiveRecord::Base.logger = Logger.new($stdout) 13 | ActiveRecord::Base.logger.level = Logger::DEBUG 14 | 15 | ActiveRecord::Base.establish_connection( 16 | adapter: "mysql2", 17 | username: "root", 18 | encoding: "utf8", 19 | host: ENV['MYSQL_HOST'] || "127.0.0.1", 20 | port: (ENV['MYSQL_PORT'] || 3601).to_i 21 | ) 22 | end 23 | 24 | def teardown 25 | ActiveRecord::Base.logger = @old_logger 26 | end 27 | 28 | test "getting metrics" do 29 | nothing_measured = { 30 | :db_time => 0, 31 | :db_calls => 0, 32 | :db_sql_query_cache_hits => 0 33 | } 34 | assert_equal nothing_measured, TimeBandits.metrics 35 | assert_equal 0, TimeBandits.consumed 36 | assert_equal 0, TimeBandits.current_runtime 37 | end 38 | 39 | test "formatting" do 40 | metrics_store.runtime += 1.234 41 | metrics_store.call_count += 3 42 | metrics_store.query_cache_hits += 1 43 | TimeBandits.consumed 44 | assert_equal "ActiveRecord: 1.234ms(3q,1h)", TimeBandits.runtime 45 | end 46 | 47 | test "accessing current runtime" do 48 | metrics_store.runtime += 1.234 49 | assert_equal 1.234, metrics_store.runtime 50 | assert_equal 1.234, bandit.current_runtime 51 | assert_equal 1.234, TimeBandits.consumed 52 | assert_equal 0, metrics_store.runtime 53 | metrics_store.runtime += 4.0 54 | assert_equal 5.234, bandit.current_runtime 55 | assert_equal "ActiveRecord: 1.234ms(0q,0h)", TimeBandits.runtime 56 | end 57 | 58 | test "sql can be executed" do 59 | event = mock('event') 60 | event.stubs(:payload).returns({name: "MURKS", sql: "SELECT 1"}) 61 | event.stubs(:duration).returns(0.1) 62 | ActiveRecord::Base.logger.expects(:debug) 63 | assert_nil log_subscriber.new.sql(event) 64 | end 65 | 66 | test "instrumentation records runtimes at log level debug" do 67 | ActiveRecord::Base.logger.stubs(:debug) 68 | ActiveRecord::Base.connection.execute "SELECT 1" 69 | bandit.consumed 70 | assert(bandit.current_runtime > 0) 71 | if ActiveRecord::VERSION::STRING >= Gem::Version.new("7.2.0") 72 | # registry ingores schema calls now 73 | assert_equal 1, bandit.calls 74 | else 75 | # 2 calls, because one configures the connection 76 | assert_equal 2, bandit.calls 77 | end 78 | assert_equal 0, bandit.sql_query_cache_hits 79 | end 80 | 81 | test "instrumentation records runtimes at log level error" do 82 | skip if Gem::Version.new(ActiveRecord::VERSION::STRING) < Gem::Version.new("7.1.0") 83 | ActiveRecord::Base.logger.level = Logger::ERROR 84 | ActiveRecord::LogSubscriber.expects(:sql).never 85 | assert_equal 0, bandit.calls 86 | ActiveRecord::Base.connection.execute "SELECT 1" 87 | bandit.consumed 88 | assert(bandit.current_runtime > 0) 89 | if ActiveRecord::VERSION::STRING >= Gem::Version.new("7.2.0") 90 | # registry ingores schema calls now 91 | assert_equal 1, bandit.calls 92 | else 93 | # 2 calls, because one configures the connection 94 | assert_equal 2, bandit.calls 95 | end 96 | assert_equal 0, bandit.sql_query_cache_hits 97 | end 98 | 99 | private 100 | 101 | def bandit 102 | TimeBandits::TimeConsumers::Database.instance 103 | end 104 | 105 | def metrics_store 106 | TimeBandits::TimeConsumers::Database.metrics_store 107 | end 108 | 109 | def log_subscriber 110 | ActiveRecord::LogSubscriber 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/time_bandits/time_consumers/garbage_collection.rb: -------------------------------------------------------------------------------- 1 | # a time consumer implementation for garbage collection 2 | module TimeBandits 3 | module TimeConsumers 4 | class GarbageCollection 5 | @@heap_dumps_enabled = false 6 | def self.heap_dumps_enabled=(v) 7 | @@heap_dumps_enabled = v 8 | end 9 | 10 | def initialize 11 | enable_stats 12 | reset 13 | end 14 | private :initialize 15 | 16 | def self.instance 17 | @instance ||= new 18 | end 19 | 20 | def enable_stats 21 | return unless GC.respond_to? :enable_stats 22 | GC.enable_stats 23 | if defined?(PhusionPassenger) 24 | PhusionPassenger.on_event(:starting_worker_process) do |forked| 25 | GC.enable_stats if forked 26 | end 27 | end 28 | end 29 | 30 | if GC.respond_to?(:time) 31 | def _get_gc_time; GC.time; end 32 | elsif GC.respond_to?(:total_time) 33 | def _get_gc_time; GC.total_time / 1000; end 34 | else 35 | def _get_gc_time; 0; end 36 | end 37 | 38 | def _get_collections; GC.count; end 39 | 40 | def _get_allocated_objects; GC.stat(:total_allocated_objects); end 41 | 42 | if GC.respond_to?(:total_malloced_bytes) 43 | def _get_allocated_size; GC.total_malloced_bytes; end 44 | else 45 | # this will wrap around :malloc_increase_bytes_limit so is not really correct 46 | def _get_allocated_size; GC.stat(:malloc_increase_bytes); end 47 | end 48 | 49 | if GC.respond_to?(:heap_slots) 50 | def _get_heap_slots; GC.heap_slots; end 51 | else 52 | def _get_heap_slots; GC.stat(:heap_live_slots) + GC.stat(:heap_free_slots) + GC.stat(:heap_final_slots); end 53 | end 54 | 55 | if GC.respond_to?(:heap_slots_live_after_last_gc) 56 | def live_data_set_size; GC.heap_slots_live_after_last_gc; end 57 | else 58 | def live_data_set_size; GC.stat(:heap_live_slots); end 59 | end 60 | 61 | def reset 62 | @consumed = _get_gc_time 63 | @collections = _get_collections 64 | @allocated_objects = _get_allocated_objects 65 | @allocated_size = _get_allocated_size 66 | @heap_slots = _get_heap_slots 67 | end 68 | 69 | def consumed 70 | 0.0 71 | end 72 | alias_method :current_runtime, :consumed 73 | 74 | def consumed_gc_time # ms 75 | (_get_gc_time - @consumed).to_f / 1000 76 | end 77 | 78 | def collections 79 | _get_collections - @collections 80 | end 81 | 82 | def allocated_objects 83 | _get_allocated_objects - @allocated_objects 84 | end 85 | 86 | def allocated_size 87 | new_size = _get_allocated_size 88 | old_size = @allocated_size 89 | new_size <= old_size ? new_size : new_size - old_size 90 | end 91 | 92 | def heap_growth 93 | _get_heap_slots - @heap_slots 94 | end 95 | 96 | GCFORMAT = "GC: %.3f(%d) | HP: %d(%d,%d,%d,%d)" 97 | 98 | def runtime 99 | heap_slots = _get_heap_slots 100 | heap_growth = self.heap_growth 101 | allocated_objects = self.allocated_objects 102 | allocated_size = self.allocated_size 103 | GCHacks.heap_dump if heap_growth > 0 && @@heap_dumps_enabled && defined?(GCHacks) 104 | GCFORMAT % [consumed_gc_time, collections, heap_growth, heap_slots, allocated_objects, allocated_size, live_data_set_size] 105 | end 106 | 107 | def metrics 108 | { 109 | :gc_time => consumed_gc_time, 110 | :gc_calls => collections, 111 | :heap_growth => heap_growth, 112 | :heap_size => _get_heap_slots, 113 | :allocated_objects => allocated_objects, 114 | :allocated_bytes => allocated_size, 115 | :live_data_set_size => live_data_set_size 116 | } 117 | end 118 | 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Time Bandits 2 | 3 | ## About 4 | 5 | Time Bandits is a gem plugin for Rails which enhances Rails' controller/view/db benchmark logging. 6 | 7 | ![Build](https://github.com/skaes/time_bandits/actions/workflows/run-tests.yml/badge.svg) 8 | 9 | 10 | ## Usage 11 | 12 | Without configuration, the standard Rails 'Completed line' will change 13 | from its default format 14 | 15 | Completed 200 OK in 56ms (Views: 28.5ms, ActiveRecord: 5.1ms) 16 | 17 | to: 18 | 19 | Completed 200 OK in 56.278ms (Views: 28.488ms, ActiveRecord: 5.111ms(2q,0h)) 20 | 21 | "ActiveRecord: 5.111ms(2q,0h)" means that 2 SQL queries were executed and there were 0 SQL query cache hits. 22 | 23 | However, non-trivial applications also rather often use external services, which consume time that adds 24 | to your total response time, and sometimes these external services are not under your control. In these 25 | cases, it's very helpful to have an entry in your log file that records the time spent in the exterrnal 26 | service (so that you can prove that it wasn't your rails app that slowed down during your slashdotting, 27 | for example ;-). 28 | 29 | Additional TimeConsumers can be added to the log using the "Timebandits.add" method. 30 | 31 | Example: 32 | 33 | TimeBandits.add TimeBandits::TimeConsumers::Memcached 34 | TimeBandits.add TimeBandits::TimeConsumers::GarbageCollection.instance if GC.respond_to? :enable_stats 35 | 36 | Here we've added two additional consumers, which are already provided with the 37 | plugin. (Note that GC information requires a patched ruby, see prerequistes below.) 38 | 39 | Note: if you run a multithreaded program, the numbers reported for garbage collections and 40 | heap usage are partially misleading, because the Ruby interpreter collects stats in global 41 | variables shared by all threads. 42 | 43 | With these two new time consumers, the log line changes to 44 | 45 | Completed 200 OK in 680.378ms (Views: 28.488ms, ActiveRecord: 5.111ms(2q,0h), MC: 5.382(6r,0m), GC: 120.100(1), HP: 0(2000000,546468,18682541,934967)) 46 | 47 | "MC: 5.382(6r,0m)" means that 6 memcache reads were performed and all keys were found in the cache (0 misses). 48 | 49 | "GC: 120.100(1)" tells us that 1 garbage collection was triggered during the request, taking 120.100 milliseconds. 50 | 51 | "HP: 0(2000000,546468,18682541,934967)" shows statistics on heap usage. The format is g(s,a,b,l), where 52 | 53 | g: heap growth during the request (#slots) 54 | s: size of the heap after request processing was completed (#slots) 55 | a: number of object allocations during the request (#slots) 56 | b: number of bytes allocated by the ruby x_malloc call (#bytes) 57 | l: live data set size after last GC (#slots) 58 | 59 | Side note for speakers of German: you can use the word "Gesabbel" (eng: drivel) as a mnemonic here ;-) 60 | 61 | It's relatively straightforward to write additional time consumers; the more difficult part of this is 62 | monkey patching the code which you want to instrument. Have a look at consumers under 63 | `lib/time_bandits/time_consumers` and the corresponding patches under `lib/time_bandits/monkey_patches`. 64 | 65 | 66 | ## Prerequisites 67 | 68 | ActiveSupport/Rails >= 5.2 is required. The gem will raise an error if you try to use it with an incompatible 69 | version. 70 | 71 | You'll need a ruby with the railsexpress GC patches applied, if you want to include GC and heap size 72 | information in the completed line. This is very useful, especially if you want to analyze your rails 73 | logs using logjam (see http://github.com/skaes/logjam/). 74 | 75 | Ruby only contains a subset of the railsexpress patches. To get the full monty, you can use for example 76 | rvm and the railsexpress rvm patchsets (see https://github.com/skaes/rvm-patchsets). 77 | 78 | 79 | ## History 80 | 81 | This plugin started from the code of the 'custom_benchmark' plugin written by tylerkovacs. However, we 82 | changed so much of the code that is is practically a full rewrite, hence we changed the name. 83 | 84 | ## Running Tests 85 | 86 | Run `docker-compose up` to start Redis, MySQL, RabbitMQ and Memached containers, then run `rake`. 87 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.14.1 4 | 5 | * Fixed that ActiveReccord metrics where only collected at log level DEBUG. 6 | 7 | ## 0.14.0 8 | 9 | * Support Rails 7.1.0. 10 | 11 | ## 0.13.1 12 | * Fixed last place that tried to log a monotonic clock timestamp 13 | instead of a Time instance. 14 | 15 | ## 0.13.0 16 | * Support redis 5.0. 17 | 18 | ## Version 0.12.7 19 | * logging start time requires a real time, not a monotonic clock 20 | 21 | ## Version 0.12.6 22 | * use ruby 3.1.1 in GitHub actions 23 | * updated appraisals 24 | * restrict Redis gem to a version before 5.0 until 5.x has become 25 | stable 26 | * use monotonic clocks for time measurements 27 | 28 | ## Version 0.12.5 29 | * use GC.stat(:malloc_increase_bytes) to measure allocated bytes as fallback 30 | * support ruby 3.1.0 GC.total_time 31 | * ruby 3.1.0 needs rails 7.0.1 to run the tests 32 | * include ruby-3.1.0 on GitHub actions 33 | 34 | ## Version 0.12.4 35 | * rails 7.0.0 compatibility 36 | * only test Rails 3.0.0 with Ruby >= 2.7.0 37 | * updated Appraisals 38 | * use safe ruby version to run tests on GitHub 39 | * switched to GitHub actions (#19) 40 | * try to fix travis 41 | * updated appraisals 42 | * remove gemfiles created by appraisals and git ignore them 43 | 44 | ## Version 0.12.3 45 | * relax minitest dependency 46 | * suppress Ruby 2.7 warnings in action controller tests 47 | * updated test container versions 48 | 49 | ## Version 0.12.2 50 | * fixed that completed line was logged twice in Rails test environment 51 | * split license into separate file 52 | * added travis badge 53 | 54 | ## Version 0.12.1 55 | * install GC time bandit automatically inRails applications 56 | * only load railtie if class Rails::Railtie is defined 57 | 58 | ## Version 0.12.0 59 | * removed leftover from old Rails plugin times 60 | * don't need sudo on travis 61 | * Merge pull request #15 from toy/patch-1 62 | * try to fix travis build 63 | * doc rephrasing 64 | * updated README 65 | * relax active support version requirement to 5.2.0 66 | * updated travis build matrix 67 | * dropped support for old rails and ruby versions 68 | * silence ruby warnings 69 | * updated docker images 70 | * require at least version 5.2.4.3 for activesupport 71 | * updated appraisals to only test supported Rails versions 72 | * damn travis 73 | * travis fu 74 | * updated ruby versions 75 | * trying to fix travis 76 | 77 | ## Version 0.11.0 78 | * added and updated appraisals 79 | * support rails 6 80 | * Add changelog url to gemspec 81 | 82 | ## Version 0.10.12 83 | * updated README 84 | * also support future 5.0.x versions 85 | * added appraisal for rails 5.0.7 86 | * Merge pull request #14 from mediafinger/master 87 | * Call type_casted_binds with only one argument in Rails 5.0.7 88 | 89 | ## Version 0.10.11 90 | * prepare 0.10.11 release 91 | * clarified how TimeBanditry gets activated in railtie 92 | * test rails 5.2.0 compatibility 93 | * Merge pull request #13 from sghosh23/update_rails_version_active_record_monkey_patch 94 | * add rails version 5.2 to support active record monkey patch 95 | * fix test failures on ActiveRecord 4.1.16 96 | * reinstalled appraisals 97 | * try to fix travis builds 98 | 99 | ## Version 0.10.10 100 | * make sure sql method can be executed on logsubscriber 101 | * updated appraisals 102 | 103 | ## Version 0.10.8 104 | * added specialized activerecord logging for Rails >= 5.1.5 105 | 106 | ## Version 0.10.8 107 | * rails has changed render_bind in 5.0.3 108 | * updated README 109 | * added rabbitmq as a service for travis 110 | * added more rails versions to test against 111 | * abort rake task when system calls fail 112 | * fixed deprecation warning for ruby 2.4.1 113 | 114 | ## Version 0.10.7 115 | * changed README format to markdown 116 | * Merge pull request #11 from manveru/patch-1 117 | * Adapt log_sql_statement for Rails 5.1 118 | * changed travis command 119 | 120 | ## Version 0.10.6 121 | * updated reales notes 122 | * Merge pull request #9 from pinglamb/master 123 | * added .travis.yml 124 | * updated rails versions for appraisals 125 | 126 | ## Version 0.10.5 127 | * make activerecord monkey patch available for rails 5.1 128 | 129 | ## Version 0.10.4 130 | * protect against Rails 5 firing on_load handlers multiple times 131 | 132 | ## Version 0.10.3 133 | * fixed activerecord logging monkey patch 134 | 135 | ## Version 0.10.2 136 | * go back to using alias_method to enable testing with rspec 137 | 138 | ## Version 0.10.1 139 | * fixed broken module.prepend 140 | 141 | ## Version 0.10.0 142 | * updated release notes 143 | * rebased on master 144 | * added docker compose file to start redis, memcached, mysql and rabbitmq for testing 145 | * added rails 5 to appraisals 146 | * active record log subscriber changes to support rails 5 147 | * checked and updated action controller hacks for rails 5 148 | * rails 5 fixed the memcache store stats bug on fetch 149 | * rails 5 deprecated alias_method_chain, used Module.prepend instead 150 | * rails 5 deprecated string values for middlewares 151 | 152 | ## Version 0.9.2 153 | * fixed sequel gem monkey patch 154 | * I really hate the stupid decision by rake to force ruby -w on everyone 155 | * updated rails versions in appraisals 156 | 157 | ## Version 0.9.1 158 | * redis time consumer: make sure to log ASCII in debug mode 159 | 160 | ## Version 0.9.0 161 | * added beetle time consumer 162 | * Multiply 1000 to get the actual millisecond 163 | 164 | ## Version 0.8.1 165 | * make sure every consumer has a current_runtime methods (duh) 166 | 167 | ## Version 0.8.0 168 | * access current database runtime including not yet consumed time 169 | 170 | ## Version 0.7.4 171 | * fixed that actions without render showed zero db time 172 | * removed .lock files 173 | * use appraisal for testing against multiple active support versions 174 | * test with rails 4.2.4 175 | 176 | ## Version 0.7.3 177 | * in rails 4.2 dalli is always instrumented 178 | * monkey patches seem to be compatible with rails 4.2 179 | 180 | ## Version 0.7.2 181 | * updated to support ruby 2.2.0 182 | 183 | ## Version 0.7.1 184 | * style change 185 | * updated README 186 | * measure time and calls with sequel 187 | 188 | ## Version 0.7.0 189 | * make the most out of an unpatched ruby 190 | 191 | ## Version 0.6.7 192 | * fixed wrong nesting of public :sql 193 | 194 | ## Version 0.6.6 195 | * fixed duplicate log lines for active record monkey patch and rails 4.1 196 | * Count redis round trips not calls 197 | 198 | ## Version 0.6.5 199 | * rails monkey patches are compatible with 4.1 200 | 201 | ## Version 0.6.4 202 | * make sure not to call 'instrument=' if a rails 4 app uses :dalli_store instead of :mem_cache_store 203 | 204 | ## Version 0.6.3 205 | * rails 3.2 columns don't understand binary? 206 | 207 | ## Version 0.6.2 208 | * rails 4.0 updates to active_record monkey_patch 209 | 210 | ## Version 0.6.1 211 | * support for ruby 2.1 212 | * added test for GC time consumer 213 | 214 | ## Version 0.6.0 215 | * updated README 216 | * added tests for dalli and redis and new completed line behavior 217 | * patched dalli consumer to work correctly with rails 4 218 | * added redis time consumer 219 | * don't include bandits in the the completed line which haven't measured anything 220 | 221 | ## Version 0.5.1 222 | * added license information to gemspec 223 | 224 | ## Version 0.5.0 225 | * ugly hack to ensure Completed lines are logged in the test environment 226 | * reset time bandits before running controller tests 227 | * renamed RailsCache consumer to dalli and rely on dalli for logging 228 | * avoid calling logger in production 229 | * install some gems for debugging 230 | * updated README 231 | * we're all milliseconds now 232 | * we are thread safe now. lose the Rack::Lock middleware 233 | * drop rails 2 support 234 | * switch database consumer to use base_consumer 235 | * added a general rails cache consumer (can be used to replace memcache consumers) 236 | * make memcache consumers threadsafe 237 | * use structs instead of hashes for counters 238 | * more groundwork for thread safe time bandits 239 | * added some tests 240 | 241 | ## Version 0.4.1 242 | * added rake dev dependency 243 | * we can't rely on Rack::Sendfile to be around 244 | 245 | ## Version 0.4.0 246 | * rails 4.0 and tagged logging support 247 | 248 | ## Version 0.3.1 249 | * need to call TimeBandits.consumed to get correct db time stats 250 | 251 | ## Version 0.3.0 252 | * use thread local variables gem 253 | * make use of thread_local_variable_access gem 254 | 255 | ## Version 0.2.2 256 | * enable GC stats after passenger has forked a new worker process 257 | * reset time bandits after rails initialization process has been completed 258 | 259 | ## Version 0.2.1 260 | * use the correct rails version specific code to extract raw_payload 261 | 262 | ## Version 0.2.0 263 | * basic rails 3.1 and 32. compatibility 264 | 265 | ## Version 0.1.4 266 | * fixed bug related to mixing seconds and millicesonds 267 | 268 | ## Version 0.1.3 269 | * db time is already measured in milliseconds 270 | 271 | ## Version 0.1.2 272 | * the Rails 3 database consumer no longer uses instance variables for the statistics 273 | 274 | ## Version 0.1.1 275 | * use own middlware logger and provide viewtime and action for logjam_agent 276 | 277 | ## Version 0.1.0 278 | * ignore some files 279 | * the version numbering is ridiculous 280 | * add a bit of backtrace info 281 | * removed last traces of agent suport 282 | * provide metrics for rack middlewares 283 | * metrics for rails2 database adapter 284 | * improved metrics for memcached 285 | 286 | ## Version 0.0.9 287 | 288 | * Relax Rails version check, still running fine on 2.3.14 289 | * metrics agent support for rails 2 290 | 291 | ## Version 0.0.8 292 | * gem compatibility for rails 2.3.x 293 | * updated README 294 | 295 | ## Version 0.0.7 296 | * git ignore the Gemfile.lock 297 | * Assume status 500 in case of an exception being raised 298 | * Fail gracefully if an exception occurs during before_dispatch 299 | 300 | ## Version 0.0.6 301 | * updated gemspec to pin to the right branch on github 302 | 303 | ## Version 0.0.5 304 | * prepare new version (now with activerecord support) 305 | * updated readme 306 | * database consumer is now thread safe 307 | * initial version of ActiveRecord time consumer 308 | 309 | 310 | ## Version 0.0.4 311 | * oh man. concentrate! 312 | 313 | ## Version 0.0.3 314 | * oops 315 | 316 | ## Version 0.0.2 317 | * refactored rack logger 318 | 319 | ## Version 0.0.1 320 | * now a proper rails3 gem plugin 321 | * removed log ouput 322 | * don't install time bandits per default (app needs control over order of bandits in completed line) 323 | * use the more accurate timing info 324 | * Merge branch 'master' into rails3 325 | * deleted unused file 326 | * Merge branch 'master' into rails3 327 | * checked in some xing modifications 328 | * first stab at supporting rails3 329 | * JRuby support for GC and memory statistics, using the jmx gem. 330 | * during initialization, enable memcache and GC stats if they are available 331 | * we are compatible to activerecord 2.3.5 332 | * Merge branch 'master' of github.com:skaes/time_bandits 333 | * time bandits registration interface changed and got a new method for creating log lines outside ActionController 334 | * added Rakefile with rdoc task 335 | * removed Rakefile 336 | * initial import 337 | --------------------------------------------------------------------------------