├── .gitignore ├── .pryrc ├── .ruby-version ├── .travis.yml ├── .yardopts ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── sidekiq-statsd.rb └── sidekiq │ └── statsd │ ├── server_middleware.rb │ └── version.rb ├── sidekiq-statsd.gemspec └── spec ├── sidekiq └── statsd │ └── server_middleware_spec.rb ├── spec_helper.rb └── support ├── shared_examples_for_a_resilient_gauge_reporter.rb └── sidekiq.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .rspec 6 | .yardoc 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp -------------------------------------------------------------------------------- /.pryrc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # -*- mode: ruby -*- 3 | # vi: set ft=ruby : 4 | 5 | require 'pathname' 6 | $LOAD_PATH.unshift(Pathname.getwd.join('lib').to_s) 7 | require 'sidekiq/throttler' 8 | 9 | def reload! 10 | Dir["#{Dir.pwd}/lib/**/*.rb"].each { |f| load f } 11 | end 12 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.4.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | services: 3 | - redis 4 | rvm: 5 | - 2.4.0 6 | - 2.5.0 7 | - 2.6.0 8 | env: 9 | - SIDEKIQ_VERSION="~> 3.3.1" 10 | - SIDEKIQ_VERSION="~> 4.0.0" 11 | - SIDEKIQ_VERSION="~> 5.0.0" 12 | - SIDEKIQ_VERSION="~> 6.0.0" 13 | jobs: 14 | exclude: 15 | # Sidekiq 6 requires Ruby 2.5+ 16 | - rvm: 2.4.0 17 | env: SIDEKIQ_VERSION="~> 6.0.0" 18 | branches: 19 | only: 20 | - master 21 | notifications: 22 | email: 23 | recipients: 24 | - pablo@pablocantero.com 25 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title 'Sidekiq::Statsd Documentation' 2 | --charset utf-8 3 | --markup markdown -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.1.0 2 | 3 | * Report stats across all workers (processing, runtime) 4 | 5 | # 2.0.1 6 | 7 | * Fix stuck global stats (retries, processed, etc.) 8 | 9 | # 2.0.0 10 | 11 | * BREAKING: drop host/port options 12 | * Add support for custom statsd client 13 | 14 | # 1.0.0 15 | 16 | * Pin minimum Ruby version to 2.4 17 | * Pin minimum Sidekiq version to 2.7 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | gem 'sidekiq', ENV['SIDEKIQ_VERSION'] if ENV['SIDEKIQ_VERSION'] 5 | 6 | group :development do 7 | gem 'rake' 8 | gem 'pry' 9 | gem 'yard' 10 | gem 'redcarpet', platforms: [:ruby] 11 | 12 | gem 'rspec' 13 | gem 'rspec-redis_helper' 14 | gem 'timecop' 15 | gem 'simplecov' 16 | 17 | gem 'guard' 18 | gem 'guard-bundler' 19 | gem 'guard-rspec' 20 | gem 'guard-yard' 21 | gem 'rb-fsevent' 22 | gem 'rb-inotify' 23 | gem 'growl' 24 | end 25 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard "bundler" do 2 | watch("Gemfile") 3 | watch("sidekiq-statsd.gemspec") 4 | end 5 | 6 | guard "rspec" do 7 | watch(%r{^spec/app/.+_worker\.rb$}) { "spec" } 8 | watch(%r{^spec/.+_spec\.rb$}) 9 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 10 | watch("spec/spec_helper.rb") { "spec" } 11 | end 12 | 13 | guard "yard" do 14 | watch(%r{app/.+\.rb}) 15 | watch(%r{lib/.+\.rb}) 16 | end 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Pablo Cantero 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sidekiq::Statsd 2 | 3 | [![Build Status](https://secure.travis-ci.org/phstc/sidekiq-statsd.png)](http://travis-ci.org/phstc/sidekiq-statsd) 4 | [![Dependency Status](https://gemnasium.com/phstc/sidekiq-statsd.png)](https://gemnasium.com/phstc/sidekiq-statsd) 5 | 6 | Sidekiq StatsD is a [Sidekiq server middleware](https://github.com/mperham/sidekiq/wiki/Middleware) to send Sidekiq metrics through [statsd](https://github.com/reinh/statsd): 7 | 8 | - [global metrics](https://github.com/mperham/sidekiq/wiki/API#wiki-stats) 9 | - [queue metrics](https://github.com/mperham/sidekiq/wiki/API#queue) 10 | - [worker metrics](https://github.com/mperham/sidekiq/wiki/API#workers) (`processing`, `runtime`) 11 | - job metrics (`processing_time` and `success` / `failure`) 12 | 13 | ## Compatibility 14 | 15 | Sidekiq::Statsd is tested against [several Ruby versions](.travis.yml#L4). 16 | 17 | ## Installation 18 | 19 | Add these lines to your application's Gemfile: 20 | 21 | gem "statsd-ruby" 22 | # or if you are using Datadog 23 | # gem "dogstatsd-ruby" 24 | gem "sidekiq-statsd" 25 | 26 | And then execute: 27 | 28 | $ bundle 29 | 30 | Or install it yourself as: 31 | 32 | $ gem install sidekiq-statsd 33 | 34 | ## Configuration 35 | 36 | In a Rails initializer or wherever you've configured Sidekiq, add 37 | Sidekiq::Statsd to your server middleware: 38 | 39 | ```ruby 40 | require 'statsd' 41 | statsd = Statsd.new('localhost', 8125) 42 | 43 | # or if you are using Datadog 44 | # require 'datadog/statsd' 45 | # statsd = Datadog::Statsd.new('localhost', 8125) 46 | 47 | Sidekiq.configure_server do |config| 48 | config.server_middleware do |chain| 49 | chain.add Sidekiq::Statsd::ServerMiddleware, env: "production", prefix: "worker", statsd: statsd 50 | end 51 | end 52 | ``` 53 | 54 | ### Sidekiq::Statsd::ServerMiddleware options 55 | 56 | ```ruby 57 | # @param [Hash] options The options to initialize the StatsD client. 58 | # @option options [Statsd] :statsd Existing [statsd client](https://github.com/github/statsd-ruby) to use. 59 | # @option options [String] :env ("production") The env to segment the metric key (e.g. env.prefix.worker_name.success|failure). 60 | # @option options [String] :prefix ("worker") The prefix to segment the metric key (e.g. env.prefix.worker_name.success|failure). 61 | # @option options [String] :sidekiq_stats ("true") Send Sidekiq global stats e.g. total enqueued, processed and failed. 62 | ``` 63 | 64 | ## Contributing 65 | 66 | 1. Fork it 67 | 2. Create your feature branch (`git checkout -b my-new-feature`) 68 | 3. Commit your changes (`git commit -am "Add some feature"`) 69 | 4. Push to the branch (`git push origin my-new-feature`) 70 | 5. Create new Pull Request 71 | 72 | ## License 73 | 74 | MIT Licensed. See [LICENSE](LICENSE) for details. 75 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | require "sidekiq/statsd/version" 4 | 5 | RSpec::Core::RakeTask.new(:spec) do |spec| 6 | spec.pattern = FileList['spec/**/*_spec.rb'] 7 | end 8 | 9 | task default: :spec 10 | -------------------------------------------------------------------------------- /lib/sidekiq-statsd.rb: -------------------------------------------------------------------------------- 1 | require "sidekiq" 2 | require "sidekiq/api" 3 | require "active_support" 4 | require "active_support/core_ext" 5 | 6 | require "sidekiq/statsd/version" 7 | require "sidekiq/statsd/server_middleware" 8 | -------------------------------------------------------------------------------- /lib/sidekiq/statsd/server_middleware.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | module Sidekiq::Statsd 4 | ## 5 | # Sidekiq StatsD is a middleware to track worker execution metrics through statsd. 6 | # 7 | class ServerMiddleware 8 | ## 9 | # Initializes the middleware with options. 10 | # 11 | # @param [Hash] options The options to initialize the StatsD client. 12 | # @option options [Statsd] :statsd Existing StatsD client. 13 | # @option options [String] :env ("production") The env to segment the metric key (e.g. env.prefix.worker_name.success|failure). 14 | # @option options [String] :prefix ("worker") The prefix to segment the metric key (e.g. env.prefix.worker_name.success|failure). 15 | # @option options [String] :sidekiq_stats ("true") Send Sidekiq global stats e.g. total enqueued, processed and failed. 16 | def initialize(options = {}) 17 | @options = { env: 'production', prefix: 'worker', sidekiq_stats: true }.merge options 18 | 19 | @statsd = options[:statsd] || raise("A StatsD client must be provided") 20 | end 21 | 22 | ## 23 | # Pushes the metrics in a batch. 24 | # 25 | # @param worker [Sidekiq::Worker] The worker the job belongs to. 26 | # @param msg [Hash] The job message. 27 | # @param queue [String] The current queue. 28 | def call worker, msg, queue 29 | @statsd.batch do |b| 30 | begin 31 | # colon causes invalid metric names 32 | worker_name = worker.class.name.gsub('::', '.') 33 | 34 | b.time prefix(worker_name, 'processing_time') do 35 | yield 36 | end 37 | 38 | b.increment prefix(worker_name, 'success') 39 | rescue => e 40 | b.increment prefix(worker_name, 'failure') 41 | raise e 42 | ensure 43 | report_global_stats(b) if @options[:sidekiq_stats] 44 | report_worker_stats(b) if @options[:sidekiq_stats] 45 | report_queue_stats(b, msg['queue']) 46 | end 47 | end 48 | end 49 | 50 | private 51 | 52 | def report_global_stats(statsd) 53 | sidekiq_stats = Sidekiq::Stats.new 54 | 55 | # Queue sizes 56 | statsd.gauge prefix('enqueued'), sidekiq_stats.enqueued 57 | statsd.gauge prefix('retry_set_size'), sidekiq_stats.retry_size 58 | 59 | # All-time counts 60 | statsd.gauge prefix('processed'), sidekiq_stats.processed 61 | statsd.gauge prefix('failed'), sidekiq_stats.failed 62 | end 63 | 64 | def report_queue_stats(statsd, queue_name) 65 | sidekiq_queue = Sidekiq::Queue.new(queue_name) 66 | statsd.gauge prefix('queues', queue_name, 'enqueued'), sidekiq_queue.size 67 | statsd.gauge prefix('queues', queue_name, 'latency'), sidekiq_queue.latency 68 | end 69 | 70 | def report_worker_stats(statsd) 71 | workers = Sidekiq::Workers.new.to_a.map { |_pid, _tid, work| work } 72 | worker_groups = workers.group_by { |worker| worker['queue'] } 73 | 74 | workers.each do |worker| 75 | runtime = Time.now.to_i - worker['run_at'] 76 | statsd.gauge prefix('queues', worker['queue'], 'runtime'), runtime 77 | end 78 | 79 | worker_groups.each do |queue_name, workers| 80 | statsd.gauge prefix('queues', queue_name, 'processing'), workers.size 81 | end 82 | end 83 | 84 | ## 85 | # Converts args passed to it into a metric name with prefix. 86 | # 87 | # @param [String] args One or more strings to be converted to a metric name. 88 | def prefix(*args) 89 | [@options[:env], @options[:prefix], *args].compact.join('.') 90 | end 91 | end # ServerMiddleware 92 | end # Sidekiq 93 | 94 | -------------------------------------------------------------------------------- /lib/sidekiq/statsd/version.rb: -------------------------------------------------------------------------------- 1 | module Sidekiq 2 | module Statsd 3 | VERSION = '2.1.0' 4 | end 5 | end 6 | 7 | -------------------------------------------------------------------------------- /sidekiq-statsd.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "sidekiq/statsd/version" 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "sidekiq-statsd" 8 | gem.version = Sidekiq::Statsd::VERSION 9 | gem.authors = ["Pablo Cantero"] 10 | gem.email = ["pablo@pablocantero.com"] 11 | gem.description = %q{Sidekiq StatsD is a Sidekiq server middleware to send Sidekiq worker metrics through statsd.} 12 | gem.summary = %q{Sidekiq StatsD is a Sidekiq server middleware to send Sidekiq worker metrics through statsd.} 13 | gem.homepage = "https://github.com/phstc/sidekiq-statsd" 14 | 15 | gem.files = `git ls-files`.split($/) 16 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 17 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 18 | gem.require_paths = ["lib"] 19 | 20 | gem.add_dependency "activesupport" 21 | gem.add_dependency "sidekiq", ">= 3.3.1" 22 | 23 | gem.required_ruby_version = '>= 2.4.0' 24 | end 25 | -------------------------------------------------------------------------------- /spec/sidekiq/statsd/server_middleware_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Sidekiq::Statsd::ServerMiddleware do 4 | subject(:middleware) { described_class.new(statsd: client) } 5 | 6 | let(:worker) { double "Dummy worker" } 7 | let(:msg) { { 'queue' => 'mailer' } } 8 | let(:queue) { nil } 9 | let(:client) { double('StatsD').as_null_object } 10 | 11 | let(:worker_name) { worker.class.name.gsub("::", ".") } 12 | 13 | let(:clean_job) { ->{} } 14 | let(:broken_job) { ->{ raise 'error' } } 15 | 16 | before do 17 | allow(client).to receive(:batch).and_yield(client) 18 | end 19 | 20 | it "raises error if no statsd supplied" do 21 | expect { described_class.new }.to raise_error("A StatsD client must be provided") 22 | end 23 | 24 | context "with customised options" do 25 | describe "#new" do 26 | it "uses the custom metric name prefix options" do 27 | expect(client) 28 | .to receive(:time) 29 | .with("development.application.sidekiq.#{worker_name}.processing_time") 30 | .once 31 | .and_yield 32 | 33 | described_class 34 | .new(statsd: client, env: 'development', prefix: 'application.sidekiq') 35 | .call(worker, msg, queue, &clean_job) 36 | end 37 | end 38 | end 39 | 40 | context 'without global sidekiq stats' do 41 | it "doesn't initialize a Sidekiq::Stats instance" do 42 | # Sidekiq::Stats.new makes redis calls 43 | expect(Sidekiq::Stats).not_to receive(:new) 44 | described_class.new(statsd: client, sidekiq_stats: false) 45 | end 46 | 47 | it "doesn't initialize a Sidekiq::Workers instance" do 48 | # Sidekiq::Workers.new makes redis calls 49 | expect(Sidekiq::Workers).not_to receive(:new) 50 | described_class.new(statsd: client, sidekiq_stats: false) 51 | end 52 | end 53 | 54 | context "with successful execution" do 55 | let(:job) { clean_job } 56 | 57 | describe "#call" do 58 | it "increments success counter" do 59 | expect(client) 60 | .to receive(:increment) 61 | .with("production.worker.#{worker_name}.success") 62 | .once 63 | 64 | middleware.call(worker, msg, queue, &job) 65 | end 66 | 67 | it "times the process execution" do 68 | expect(client) 69 | .to receive(:time) 70 | .with("production.worker.#{worker_name}.processing_time") 71 | .once 72 | .and_yield 73 | 74 | middleware.call(worker, msg, queue, &job) 75 | end 76 | end 77 | 78 | it_behaves_like "a resilient gauge reporter" 79 | end 80 | 81 | context "with failed execution" do 82 | let(:job) { broken_job } 83 | 84 | describe "#call" do 85 | before do 86 | allow(client) 87 | .to receive(:time) 88 | .with("production.worker.#{worker_name}.processing_time") 89 | .and_yield 90 | end 91 | 92 | it "increments failure counter" do 93 | expect(client) 94 | .to receive(:increment) 95 | .with("production.worker.#{worker_name}.failure") 96 | .once 97 | 98 | expect{ middleware.call(worker, msg, queue, &job) }.to raise_error('error') 99 | end 100 | end 101 | 102 | it_behaves_like "a resilient gauge reporter" 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 2 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib")) 3 | 4 | unless ENV["CI"] 5 | require "simplecov" 6 | SimpleCov.start do 7 | add_filter "/spec/" 8 | add_filter "/vendor/" 9 | end 10 | end 11 | 12 | require "sidekiq-statsd" 13 | 14 | RSpec.configure do |config| 15 | # Run specs in random order to surface order dependencies. If you find an 16 | # order dependency and want to debug it, you can fix the order by providing 17 | # the seed, which is printed after each run. 18 | # --seed 1234 19 | config.order = "random" 20 | end 21 | 22 | # Requires supporting files with custom matchers and macros, etc, 23 | # in ./support/ and its subdirectories. 24 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 25 | -------------------------------------------------------------------------------- /spec/support/shared_examples_for_a_resilient_gauge_reporter.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/testing/time_helpers' 2 | 3 | shared_examples "a resilient gauge reporter" do 4 | include ActiveSupport::Testing::TimeHelpers 5 | 6 | let(:sidekiq_stats) { double(enqueued: 1, processed: 2, failed: 3, retry_size: 4) } 7 | let!(:sidekiq_workers) { [["pid", "tid", { "queue" => "my_queue", "run_at" => Time.now.to_i }]] } 8 | let(:queue_stats) { double(size: 3, latency: 4.2) } 9 | 10 | before do 11 | allow(Sidekiq::Stats).to receive(:new) { sidekiq_stats } 12 | allow(Sidekiq::Queue).to receive(:new).with('mailer') { queue_stats } 13 | allow(Sidekiq::Workers).to receive(:new) { sidekiq_workers } 14 | end 15 | 16 | it "gauges enqueued jobs" do 17 | expect(client) 18 | .to receive(:gauge) 19 | .with("production.worker.enqueued", 1) 20 | .once 21 | 22 | middleware.call(worker, msg, queue, &job) 23 | end 24 | 25 | it "gauges processed jobs" do 26 | expect(client) 27 | .to receive(:gauge) 28 | .with("production.worker.processed", 2) 29 | .once 30 | 31 | middleware.call(worker, msg, queue, &job) 32 | end 33 | 34 | it "gauges failed jobs" do 35 | expect(client) 36 | .to receive(:gauge) 37 | .with("production.worker.failed", 3) 38 | .once 39 | 40 | middleware.call(worker, msg, queue, &job) 41 | end 42 | 43 | it "gauges retry set size" do 44 | expect(client) 45 | .to receive(:gauge) 46 | .with("production.worker.retry_set_size", 4) 47 | .once 48 | 49 | middleware.call(worker, msg, queue, &job) 50 | end 51 | 52 | it "gauges queue depth" do 53 | expect(client) 54 | .to receive(:gauge) 55 | .with("production.worker.queues.mailer.enqueued", 3) 56 | .once 57 | 58 | middleware.call(worker, msg, queue, &job) 59 | end 60 | 61 | it "gauges queue latency" do 62 | expect(client) 63 | .to receive(:gauge) 64 | .with("production.worker.queues.mailer.latency", 4.2) 65 | .once 66 | 67 | middleware.call(worker, msg, queue, &job) 68 | end 69 | 70 | it "gauges precessing jobs" do 71 | expect(client) 72 | .to receive(:gauge) 73 | .with("production.worker.queues.my_queue.processing", 1) 74 | .once 75 | 76 | middleware.call(worker, msg, queue, &job) 77 | end 78 | 79 | it "gauges job runtime" do 80 | travel_to 5.minutes.from_now 81 | 82 | expect(client) 83 | .to receive(:gauge) 84 | .with("production.worker.queues.my_queue.runtime", 300) 85 | .once 86 | 87 | middleware.call(worker, msg, queue, &job) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/support/sidekiq.rb: -------------------------------------------------------------------------------- 1 | require "rspec-redis_helper" 2 | 3 | RSpec.configure do |spec| 4 | spec.include RSpec::RedisHelper, redis: true 5 | 6 | # clean the Redis database around each run 7 | # @see https://www.relishapp.com/rspec/rspec-core/docs/hooks/around-hooks 8 | spec.around(:each, redis: true) do |example| 9 | with_clean_redis do 10 | example.run 11 | end 12 | end 13 | end 14 | --------------------------------------------------------------------------------