├── .gitignore ├── LICENSE ├── README.md ├── Rakefile ├── changelog.md ├── lib └── rack-statsd.rb └── rack-statsd.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) GitHub, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NOTE: This repository is no longer supported or updated by GitHub. If you wish to continue to develop this code yourself, we recommend you fork it.** 2 | 3 | # RackStatsD 4 | 5 | Some tiny middleware for monitoring Rack apps in production. 6 | 7 | * RackStatsD::RequestStatus - Adds a status URL for health checks. 8 | * RackStatsD::RequestHostname - Shows which what code is running on 9 | which node for a given request. 10 | * RackStatsD::ProcessUtilization - Tracks how long Unicorns spend 11 | processing requests. Optionally sends metrics to a StatsD server. 12 | 13 | Note: The request tracking code isn't thread safe. It should work fine 14 | for apps on Unicorn. 15 | 16 | This code has been extracted from GitHub.com and is used on 17 | http://git.io currently. 18 | 19 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | require 'date' 4 | 5 | ############################################################################# 6 | # 7 | # Helper functions 8 | # 9 | ############################################################################# 10 | 11 | def name 12 | @name ||= Dir['*.gemspec'].first.split('.').first 13 | end 14 | 15 | def version 16 | line = File.read("lib/#{name}.rb")[/^\s*VERSION\s*=\s*.*/] 17 | line.match(/.*VERSION\s*=\s*['"](.*)['"]/)[1] 18 | end 19 | 20 | def date 21 | Date.today.to_s 22 | end 23 | 24 | def rubyforge_project 25 | name 26 | end 27 | 28 | def gemspec_file 29 | "#{name}.gemspec" 30 | end 31 | 32 | def gem_file 33 | "#{name}-#{version}.gem" 34 | end 35 | 36 | def replace_header(head, header_name) 37 | head.sub!(/(\.#{header_name}\s*= ').*'/) { "#{$1}#{send(header_name)}'"} 38 | end 39 | 40 | ############################################################################# 41 | # 42 | # Standard tasks 43 | # 44 | ############################################################################# 45 | 46 | task :default => :test 47 | 48 | if false 49 | require 'rake/testtask' 50 | Rake::TestTask.new(:test) do |test| 51 | test.libs << 'lib' << 'test' 52 | test.pattern = 'test/**/*_test.rb' 53 | test.verbose = true 54 | end 55 | else 56 | task :test do 57 | puts "haven't setup tests yet" 58 | end 59 | end 60 | 61 | desc "Open an irb session preloaded with this library" 62 | task :console do 63 | sh "irb -rubygems -r ./lib/#{name}.rb" 64 | end 65 | 66 | ############################################################################# 67 | # 68 | # Custom tasks (add your own tasks here) 69 | # 70 | ############################################################################# 71 | 72 | 73 | 74 | ############################################################################# 75 | # 76 | # Packaging tasks 77 | # 78 | ############################################################################# 79 | 80 | desc "Create tag v#{version} and build and push #{gem_file} to Rubygems" 81 | task :release => :build do 82 | unless `git branch` =~ /^\* master$/ 83 | puts "You must be on the master branch to release!" 84 | exit! 85 | end 86 | sh "git commit --allow-empty -a -m 'Release #{version}'" 87 | sh "git tag v#{version}" 88 | sh "git push origin master" 89 | sh "git push origin v#{version}" 90 | sh "gem push pkg/#{name}-#{version}.gem" 91 | end 92 | 93 | desc "Build #{gem_file} into the pkg directory" 94 | task :build => :gemspec do 95 | sh "mkdir -p pkg" 96 | sh "gem build #{gemspec_file}" 97 | sh "mv #{gem_file} pkg" 98 | end 99 | 100 | desc "Generate #{gemspec_file}" 101 | task :gemspec => :validate do 102 | # read spec file and split out manifest section 103 | spec = File.read(gemspec_file) 104 | head, manifest, tail = spec.split(" # = MANIFEST =\n") 105 | 106 | # replace name version and date 107 | replace_header(head, :name) 108 | replace_header(head, :version) 109 | replace_header(head, :date) 110 | #comment this out if your rubyforge_project has a different name 111 | replace_header(head, :rubyforge_project) 112 | 113 | # determine file list from git ls-files 114 | files = `git ls-files`. 115 | split("\n"). 116 | sort. 117 | reject { |file| file =~ /^\./ }. 118 | reject { |file| file =~ /^(rdoc|pkg)/ }. 119 | map { |file| " #{file}" }. 120 | join("\n") 121 | 122 | # piece file back together and write 123 | manifest = " s.files = %w[\n#{files}\n ]\n" 124 | spec = [head, manifest, tail].join(" # = MANIFEST =\n") 125 | File.open(gemspec_file, 'w') { |io| io.write(spec) } 126 | puts "Updated #{gemspec_file}" 127 | end 128 | 129 | desc "Validate #{gemspec_file}" 130 | task :validate do 131 | libfiles = Dir['lib/*'] - ["lib/#{name}.rb", "lib/#{name}"] 132 | unless libfiles.empty? 133 | puts "Directory `lib` should only contain a `#{name}.rb` file and `#{name}` dir." 134 | exit! 135 | end 136 | unless Dir['VERSION*'].empty? 137 | puts "A `VERSION` file at root level violates Gem best practices." 138 | exit! 139 | end 140 | end 141 | 142 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ## v0.2.0 2 | 3 | _Back from the Dead_ Release 4 | 5 | * Response times are tracked by HTTP status. [@mheffner] 6 | * RequestStatus is Lint compliant. [@kb] 7 | * Way more status codes are tracked. [@adelcambre] 8 | 9 | ## v0.1.1 10 | 11 | * Fixes for RequestStatus response. [@atmos] 12 | 13 | ## v0.1.0 14 | 15 | Initial Release 16 | -------------------------------------------------------------------------------- /lib/rack-statsd.rb: -------------------------------------------------------------------------------- 1 | module RackStatsD 2 | VERSION = "0.2.1" 3 | 4 | # Simple middleware to add a quick status URL for tools like Nagios. 5 | class RequestStatus 6 | REQUEST_METHOD = 'REQUEST_METHOD'.freeze 7 | GET = 'GET'.freeze 8 | PATH_INFO = 'PATH_INFO'.freeze 9 | STATUS_PATH = '/status' 10 | HEADERS = {"Content-Type" => "text/plain"}.freeze 11 | 12 | # Initializes the middleware. 13 | # 14 | # # Responds with "OK" on /status 15 | # use RequestStatus, "OK" 16 | # 17 | # You can change what URL to look for: 18 | # 19 | # use RequestStatus, "OK", "/ping" 20 | # 21 | # You can also check internal systems and return something more informative. 22 | # 23 | # use RequestStatus, lambda { 24 | # status = MyApp.status # A Hash of some live counters or something 25 | # [200, {"Content-Type" => "application/json"}, status.to_json] 26 | # } 27 | # 28 | # app - The next Rack app in the pipeline. 29 | # callback_or_response - Either a Proc or a Rack response. 30 | # status_path - Optional String path that returns the status. 31 | # Default: "/status" 32 | # 33 | # Returns nothing. 34 | def initialize(app, callback_or_response, status_path = nil) 35 | @app = app 36 | @status_path = (status_path || STATUS_PATH).freeze 37 | @callback = callback_or_response 38 | end 39 | 40 | def call(env) 41 | if env[REQUEST_METHOD] == GET 42 | if env[PATH_INFO] == @status_path 43 | if @callback.respond_to?(:call) 44 | return @callback.call 45 | else 46 | return [200, HEADERS, [@callback.to_s]] 47 | end 48 | end 49 | end 50 | 51 | @app.call env 52 | end 53 | end 54 | 55 | # Simple middleware that adds the current host name and current git SHA to 56 | # the response headers. This can help diagnose problems by letting you 57 | # know what code is running from what machine. 58 | class RequestHostname 59 | # Initializes the middlware. 60 | # 61 | # app - The next Rack app in the pipeline. 62 | # options - Hash of options. 63 | # :host - String hostname. 64 | # :revision - String SHA that describes the version of code 65 | # this process is running. 66 | # 67 | # Returns nothing. 68 | def initialize(app, options = {}) 69 | @app = app 70 | @host = options.key?(:host) ? options[:host] : `hostname -s`.chomp 71 | @sha = options[:revision] || '' 72 | end 73 | 74 | def call(env) 75 | status, headers, body = @app.call(env) 76 | headers['X-Node'] = @host if @host 77 | headers['X-Revision'] = @sha 78 | [status, headers, body] 79 | end 80 | end 81 | 82 | # Middleware that tracks the amount of time this process spends processing 83 | # requests, as opposed to being idle waiting for a connection. Statistics 84 | # are dumped to rack.errors every 5 minutes. 85 | # 86 | # NOTE This middleware is not thread safe. It should only be used when 87 | # rack.multiprocess is true and rack.multithread is false. 88 | class ProcessUtilization 89 | REQUEST_METHOD = 'REQUEST_METHOD'.freeze 90 | VALID_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'].freeze 91 | 92 | # Initializes the middleware. 93 | # 94 | # app - The next Rack app in the pipeline. 95 | # domain - The String domain name the app runs in. 96 | # revision - The String SHA that describes the current version of code. 97 | # options - Hash of options. 98 | # :window - The Integer number of seconds before the 99 | # horizon resets. 100 | # :stats - Optional StatsD client. 101 | # :hostname - Optional String hostname. Set to nil 102 | # to exclude. 103 | # :stats_prefix - Optional String prefix for StatsD keys. 104 | # Default: "rack" 105 | def initialize(app, domain, revision, options = {}) 106 | @app = app 107 | @domain = domain 108 | @revision = revision 109 | @window = options[:window] || 100 110 | @horizon = nil 111 | @active_time = nil 112 | @requests = nil 113 | @total_requests = 0 114 | @worker_number = nil 115 | @track_gc = GC.respond_to?(:time) 116 | 117 | if @stats = options[:stats] 118 | prefix = [options[:stats_prefix] || :rack] 119 | if options.has_key?(:hostname) 120 | prefix << options[:hostname] unless options[:hostname].nil? 121 | else 122 | prefix << `hostname -s`.chomp 123 | end 124 | @stats_prefix = prefix.join(".") 125 | end 126 | end 127 | 128 | # the app's domain name - shown in proctitle 129 | attr_accessor :domain 130 | 131 | # the currently running git revision as a 7-sha 132 | attr_accessor :revision 133 | 134 | # time when we began sampling. this is reset every once in a while so 135 | # averages don't skew over time. 136 | attr_accessor :horizon 137 | 138 | # total number of requests that have been processed by this worker since 139 | # the horizon time. 140 | attr_accessor :requests 141 | 142 | # decimal number of seconds the worker has been active within a request 143 | # since the horizon time. 144 | attr_accessor :active_time 145 | 146 | # total requests processed by this worker process since it started 147 | attr_accessor :total_requests 148 | 149 | # the unicorn worker number 150 | attr_accessor :worker_number 151 | 152 | # the amount of time since the horizon 153 | def horizon_time 154 | Time.now - horizon 155 | end 156 | 157 | # decimal number of seconds this process has been active since the horizon 158 | # time. This is the inverse of the active time. 159 | def idle_time 160 | horizon_time - active_time 161 | end 162 | 163 | # percentage of time this process has been active since the horizon time. 164 | def percentage_active 165 | (active_time / horizon_time) * 100 166 | end 167 | 168 | # percentage of time this process has been idle since the horizon time. 169 | def percentage_idle 170 | (idle_time / horizon_time) * 100 171 | end 172 | 173 | # number of requests processed per second since the horizon 174 | def requests_per_second 175 | requests / horizon_time 176 | end 177 | 178 | # average response time since the horizon in milliseconds 179 | def average_response_time 180 | (active_time / requests.to_f) * 1000 181 | end 182 | 183 | # called exactly once before the first request is processed by a worker 184 | def first_request 185 | reset_horizon 186 | record_worker_number 187 | end 188 | 189 | # resets the horizon and all dependent variables 190 | def reset_horizon 191 | @horizon = Time.now 192 | @active_time = 0.0 193 | @requests = 0 194 | end 195 | 196 | # extracts the worker number from the unicorn procline 197 | def record_worker_number 198 | if $0 =~ /^.* worker\[(\d+)\].*$/ 199 | @worker_number = $1.to_i 200 | else 201 | @worker_number = nil 202 | end 203 | end 204 | 205 | # the generated procline 206 | def procline 207 | "unicorn %s[%s] worker[%02d]: %5d reqs, %4.1f req/s, %4dms avg, %5.1f%% util" % [ 208 | domain, 209 | revision, 210 | worker_number.to_i, 211 | total_requests.to_i, 212 | requests_per_second.to_f, 213 | average_response_time.to_i, 214 | percentage_active.to_f 215 | ] 216 | end 217 | 218 | # called immediately after a request to record statistics, update the 219 | # procline, and dump information to the logfile 220 | def record_request(status, env) 221 | now = Time.now 222 | diff = (now - @start) 223 | @active_time += diff 224 | @requests += 1 225 | 226 | $0 = procline 227 | 228 | if @stats 229 | @stats.timing("#{@stats_prefix}.response_time", diff * 1000) 230 | if VALID_METHODS.include?(env[REQUEST_METHOD]) 231 | stat = "#{@stats_prefix}.response_time.#{env[REQUEST_METHOD].downcase}" 232 | @stats.timing(stat, diff * 1000) 233 | end 234 | 235 | if suffix = status_suffix(status) 236 | @stats.increment "#{@stats_prefix}.status_code.#{status_suffix(status)}" 237 | end 238 | if @track_gc && GC.time > 0 239 | @stats.timing "#{@stats_prefix}.gc.time", GC.time / 1000 240 | @stats.count "#{@stats_prefix}.gc.collections", GC.collections 241 | end 242 | end 243 | 244 | reset_horizon if now - horizon > @window 245 | rescue => boom 246 | warn "ProcessUtilization#record_request failed: #{boom}" 247 | end 248 | 249 | def status_suffix(status) 250 | suffix = case status.to_i 251 | when 200 then :ok 252 | when 201 then :created 253 | when 202 then :accepted 254 | when 301 then :moved_permanently 255 | when 302 then :found 256 | when 303 then :see_other 257 | when 304 then :not_modified 258 | when 305 then :use_proxy 259 | when 307 then :temporary_redirect 260 | when 400 then :bad_request 261 | when 401 then :unauthorized 262 | when 402 then :payment_required 263 | when 403 then :forbidden 264 | when 404 then :missing 265 | when 410 then :gone 266 | when 422 then :invalid 267 | when 500 then :error 268 | when 502 then :bad_gateway 269 | when 503 then :node_down 270 | when 504 then :gateway_timeout 271 | end 272 | end 273 | 274 | # Body wrapper. Yields to the block when body is closed. This is used to 275 | # signal when a response is fully finished processing. 276 | class Body 277 | def initialize(body, &block) 278 | @body = body 279 | @block = block 280 | end 281 | 282 | def each(&block) 283 | if @body.respond_to?(:each) 284 | @body.each(&block) 285 | else 286 | block.call(@body) 287 | end 288 | end 289 | 290 | def close 291 | @body.close if @body.respond_to?(:close) 292 | @block.call 293 | nil 294 | end 295 | end 296 | 297 | # Rack entry point. 298 | def call(env) 299 | @start = Time.now 300 | GC.clear_stats if @track_gc 301 | 302 | @total_requests += 1 303 | first_request if @total_requests == 1 304 | 305 | env['process.request_start'] = @start.to_f 306 | env['process.total_requests'] = total_requests 307 | 308 | # newrelic X-Request-Start 309 | env.delete('HTTP_X_REQUEST_START') 310 | 311 | status, headers, body = @app.call(env) 312 | body = Body.new(body) { record_request(status, env) } 313 | [status, headers, body] 314 | end 315 | end 316 | end 317 | 318 | -------------------------------------------------------------------------------- /rack-statsd.gemspec: -------------------------------------------------------------------------------- 1 | ## This is the rakegem gemspec template. Make sure you read and understand 2 | ## all of the comments. Some sections require modification, and others can 3 | ## be deleted if you don't need them. Once you understand the contents of 4 | ## this file, feel free to delete any comments that begin with two hash marks. 5 | ## You can find comprehensive Gem::Specification documentation, at 6 | ## http://docs.rubygems.org/read/chapter/20 7 | Gem::Specification.new do |s| 8 | s.specification_version = 2 if s.respond_to? :specification_version= 9 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 10 | s.rubygems_version = '1.3.5' 11 | 12 | ## Leave these as is they will be modified for you by the rake gemspec task. 13 | ## If your rubyforge_project name is different, then edit it and comment out 14 | ## the sub! line in the Rakefile 15 | s.name = 'rack-statsd' 16 | s.version = '0.2.1' 17 | s.date = '2013-04-15' 18 | s.rubyforge_project = 'rack-statsd' 19 | 20 | ## Make sure your summary is short. The description may be as long 21 | ## as you like. 22 | s.summary = "Tools for monitoring Rack apps in production." 23 | s.description = "Tools for monitoring Rack apps in production." 24 | 25 | ## List the primary authors. If there are a bunch of authors, it's probably 26 | ## better to set the email to an email list or something. If you don't have 27 | ## a custom homepage, consider using your GitHub URL or the like. 28 | s.authors = ["Ryan Tomayko", "Rick Olson"] 29 | s.email = 'technoweenie@gmail.com' 30 | s.homepage = 'https://github.com/github/rack-statsd' 31 | 32 | ## This gets added to the $LOAD_PATH so that 'lib/NAME.rb' can be required as 33 | ## require 'NAME.rb' or'/lib/NAME/file.rb' can be as require 'NAME/file.rb' 34 | s.require_paths = %w[lib] 35 | 36 | ## List your runtime dependencies here. Runtime dependencies are those 37 | ## that are needed for an end user to actually USE your code. 38 | #s.add_dependency('rack', "~> 1.2.6") 39 | 40 | ## List your development dependencies here. Development dependencies are 41 | ## those that are only needed during development 42 | s.add_development_dependency('rack-test') 43 | 44 | ## Leave this section as-is. It will be automatically generated from the 45 | ## contents of your Git repository via the gemspec task. DO NOT REMOVE 46 | ## THE MANIFEST COMMENTS, they are used as delimiters by the task. 47 | # = MANIFEST = 48 | s.files = %w[ 49 | LICENSE 50 | README.md 51 | Rakefile 52 | changelog.md 53 | lib/rack-statsd.rb 54 | rack-statsd.gemspec 55 | ] 56 | # = MANIFEST = 57 | 58 | ## Test files will be grabbed from the file list. Make sure the path glob 59 | ## matches what you actually use. 60 | s.test_files = s.files.select { |path| path =~ /^test\/.*_test\.rb/ } 61 | end 62 | 63 | --------------------------------------------------------------------------------