├── .gitignore
├── .travis.yml
├── Gemfile
├── Gemfile.lock
├── LICENSE.txt
├── README.md
├── Rakefile
├── config
├── example_config.rb
└── example_config.ru
├── lib
├── unicorn_metrics.rb
└── unicorn_metrics
│ ├── counter.rb
│ ├── default_http_metrics.rb
│ ├── middleware.rb
│ ├── registry.rb
│ ├── request_counter.rb
│ ├── request_timer.rb
│ ├── response_counter.rb
│ ├── timer.rb
│ └── version.rb
├── test
├── test_counter.rb
├── test_helper.rb
├── test_middleware.rb
├── test_registry.rb
├── test_request_counter.rb
├── test_request_timer.rb
├── test_response_counter.rb
├── test_timer.rb
└── test_unicorn_metrics.rb
└── unicorn_metrics.gemspec
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | *.rbc
3 | .bundle
4 | .config
5 | .yardoc
6 | Gemfile.lock
7 | InstalledFiles
8 | _yardoc
9 | coverage
10 | doc/
11 | lib/bundler/man
12 | pkg
13 | rdoc
14 | spec/reports
15 | test/tmp
16 | test/version_tmp
17 | tmp
18 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - "1.9.3"
4 | - "2.0.0"
5 |
6 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | # Specify your gem's dependencies in unicorn_metrics.gemspec
4 | gemspec
5 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | rake: ^12.3.3
2 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright 2013 The Climate Corporation and contributors.
2 |
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 |
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/TheClimateCorporation/unicorn-metrics) [](https://codeclimate.com/github/TheClimateCorporation/unicorn-metrics)
2 |
3 | # UnicornMetrics
4 |
5 | Gather metrics from a Ruby application. Specifically targeted at Rack-based applications that use the [Unicorn](http://unicorn.bogomips.org) preforking webserver
6 |
7 | ## Installation
8 |
9 | Add this line to your application's Gemfile:
10 |
11 | gem 'unicorn_metrics'
12 |
13 | And then execute:
14 |
15 | $ bundle
16 |
17 | Or install it yourself as:
18 |
19 | $ gem install unicorn_metrics
20 |
21 | ## Usage
22 |
23 | ### Counters
24 |
25 | UnicornMetrics::Counter implements a convenient wrapper around an atomic counter.
26 | Register new counters in the application:
27 |
28 | ```ruby
29 | UnicornMetrics.configure do |c|
30 | # Configure a new counter with the name 'test_counter'
31 | # Then access this counter UnicornMetrics.test_counter
32 | # e.g., UnicornMetrics.test_counter.increment
33 | #
34 | c.register(:counter, "test_counter")
35 | #
36 | end
37 | ```
38 |
39 | Register a new counter,
40 |
41 | ```ruby
42 | UnicornMetrics.configure do |c|
43 | c.register(:counter, "test_counter")
44 | end
45 | ```
46 |
47 | Use it in the application
48 |
49 | ```ruby
50 | >> counter = UnicornMetrics.test_counter
51 |
52 | # Getting the count
53 | >> counter.count
54 | #=> 0
55 |
56 | # Incrementing
57 | >> 5.times { counter.increment }
58 | >> counter.count
59 | #=> 5
60 |
61 | # Decrementing
62 | >> 5.times { counter.decrement }
63 | >> counter.count
64 | #=> 0
65 |
66 | # Resetting
67 | >> 5.times { counter.increment }
68 | >> counter.reset
69 | >> counter.count
70 | #=> 0
71 | ```
72 |
73 | ### Timers
74 |
75 | UnicornMetrics::Timer implements a Timer object that tracks elapsed time and ticks.
76 |
77 | Register a new timer,
78 |
79 | ```ruby
80 | UnicornMetrics.configure do |c|
81 | c.register(:timer, "test_timer")
82 | end
83 | ```
84 |
85 | Use it in the application
86 |
87 | ```ruby
88 | >> timer = UnicornMetrics.test_timer
89 |
90 | # Time some action
91 | >> elapsed_time = Benchmark.realtime { sleep(10) }
92 |
93 | # Record it in the timer
94 | >> timer.tick(elapsed_time)
95 |
96 | # Get the total elapsed time
97 | # We get 3 significant digits after the decimal point
98 | >> timer.sum
99 | => 10.001
100 |
101 | # Reset the timer
102 | >> timer.reset
103 | >> timer.sum
104 | => 0.0
105 | ```
106 |
107 | ### Gauges
108 |
109 | TODO
110 |
111 | ### HTTP Request/Response Counters and Request Timers
112 |
113 | Register a `UnicornMetrics::ResponseCounter` or `UnicornMetrics::RequestCounter` to track
114 | the response status code or request method to a specified path.
115 |
116 | ```ruby
117 | # Path is optional
118 | >> UnicornMetrics.register(:response_counter, "responses.2xx", /[2]\d{2}/)
119 |
120 | # Request counter with a 'path' argument
121 | >> UnicornMetrics.register(:request_counter, "requests.POST", 'POST', /^\/my_endpoint\/$/)
122 | ```
123 |
124 | HTTP metrics must be enabled in the config file.
125 |
126 | ```ruby
127 | # Rails.root/config/initializers/unicorn_metrics.rb
128 |
129 | UnicornMetrics.configure do |c|
130 | c.http_metrics = true #Default false
131 | end
132 | ```
133 |
134 | This will give you these timers and counters for free: "responses.4xx", "responses.5xx", "responses.2xx", "responses.3xx"
135 | "requests.POST", "requests.PUT", "requests.GET", "requests.DELETE"
136 |
137 | ## Middleware
138 | Included middleware to support exposing metrics to an endpoint. These can then be consumed
139 | by a service that publishes to [Graphite](http://graphite.wikidot.com/).
140 |
141 | Important: this middleware builds upon the standard Raindrops middleware.
142 | It is currently set to provide the default Raindrops data as part of the metrics response
143 |
144 | Add to the top of the middleware stack in `config.ru`:
145 |
146 | ```ruby
147 | # config.ru
148 |
149 | require 'unicorn_metrics/middleware'
150 | use UnicornMetrics::Middleware, :path => "/metrics"
151 | # other middleware...
152 | run N::Application
153 | ```
154 |
155 | Metrics will be published to the defined path (i.e., http://localhost:3000/metrics )
156 |
157 | ```javascript
158 | {
159 | // A custom Request Timer added here
160 | // See example_config.rb
161 | "api/v1/custom/id.GET": {
162 | "type": "timer",
163 | "sum": 0.0,
164 | "value": 0
165 | },
166 | "responses.2xx": {
167 | "type": "counter",
168 | "value": 1
169 | },
170 | "responses.3xx": {
171 | "type": "counter",
172 | "value": 19
173 | },
174 | "responses.4xx": {
175 | "type": "counter",
176 | "value": 0
177 | },
178 | "responses.5xx": {
179 | "type": "counter",
180 | "value": 0
181 | },
182 | "requests.GET": {
183 | "type": "timer",
184 | "sum": 1.666,
185 | "value": 20
186 | },
187 | "requests.POST": {
188 | "type": "timer",
189 | "sum": 0.0,
190 | "value": 0
191 | },
192 | "requests.DELETE": {
193 | "type": "timer",
194 | "sum": 0.0,
195 | "value": 0
196 | },
197 | "requests.HEAD": {
198 | "type": "timer",
199 | "sum": 0.0,
200 | "value": 0
201 | },
202 | "requests.PUT": {
203 | "type": "timer",
204 | "sum": 0.0,
205 | "value": 0
206 | },
207 | "raindrops.calling": {
208 | "type": "gauge",
209 | "value": 0
210 | },
211 | "raindrops.writing": {
212 | "type": "gauge",
213 | "value": 0
214 | },
215 | // This will only work on Linux platforms as specified by the Raindrops::Middleware
216 | // Listeners on TCP sockets
217 | "raindrops.tcp.active": {
218 | "type": "gauge",
219 | "value": 0
220 | },
221 | // Listeners on Unix sockets
222 | "raindrops.unix.active": {
223 | "type": "gauge",
224 | "value": 0
225 | }
226 | }
227 | ```
228 |
229 | ## Contributing
230 |
231 | 1. Fork it
232 | 2. Create your feature branch (`git checkout -b my-new-feature`)
233 | 3. Commit your changes (`git commit -am 'Add some feature'`)
234 | 4. Push to the branch (`git push origin my-new-feature`)
235 | 5. Create new Pull Request
236 |
237 |
238 | ## TODO:
239 |
240 | - Implement additional metric types
241 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/gem_tasks"
2 | require 'rake/testtask'
3 |
4 | Rake::TestTask.new do |t|
5 | t.libs << 'test'
6 | t.verbose = true
7 | end
8 |
9 | desc "Run tests"
10 | task :default => :test
11 |
--------------------------------------------------------------------------------
/config/example_config.rb:
--------------------------------------------------------------------------------
1 | UnicornMetrics.configure do |c|
2 | # Example:
3 | # c.register(:counter, "counter_one")
4 | # UnicornMetrics.counter_one.increment
5 | #
6 | # Names can be separated by dots and spaces, but the getter methods will use underscores:
7 | # c.register(:counter, "counter.one")
8 | # UnicornMetrics.counter_one.increment
9 |
10 | # Set to true to create counters for endpoint statistics
11 | # Several high-level statistics are provided for free:
12 | #
13 | # "responses.4xx", "responses.5xx", "responses.2xx", "responses.3xx"
14 | # "requests.POST", "requests.PUT", "requests.GET", "requests.DELETE"
15 | #
16 | c.http_metrics = true # Default false
17 |
18 | # Register a timer for GET requests to URIs that match api/v1/path/
19 | c.register(:request_timer, "api/v1/path/id.GET", 'GET', %r{\/api\/v1\/path\/\d+})
20 |
21 | # Register a counter for all 200 responses (path not specified)
22 | c.register(:response_counter, "responses.200", /200/)
23 | end
24 |
--------------------------------------------------------------------------------
/config/example_config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require ::File.expand_path('../config/environment', __FILE__)
4 | require 'unicorn_metrics/middleware'
5 |
6 | # Replaces Raindrops::Middleware
7 | use UnicornMetrics::Middleware, :listeners => %w(0.0.0.0:7180 /tmp/clemens.sock), :metrics => "/metrics"
8 |
9 | run ApplicationName::Application
10 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics.rb:
--------------------------------------------------------------------------------
1 | module UnicornMetrics
2 | class << self
3 |
4 | # Returns the UnicornMetrics::Registry object
5 | #
6 | # @return [UnicornMetrics::Registry]
7 | def registry
8 | UnicornMetrics::Registry
9 | end
10 |
11 | # Make this class 'configurable'
12 | #
13 | # @yieldparam self [UnicornMetrics]
14 | def configure
15 | yield self
16 | end
17 |
18 | # Enable/disable HTTP metrics. Includes defaults
19 | #
20 | # @param boolean [Boolean] to enable or disable default HTTP metrics
21 | def http_metrics=(boolean=false)
22 | return if @_assigned
23 |
24 | if @http_metrics = boolean
25 | registry.extend(UnicornMetrics::DefaultHttpMetrics)
26 | registry.register_default_http_counters
27 | registry.register_default_http_timers
28 | end
29 | @_assigned = true
30 | end
31 |
32 | # Used by the middleware to determine whether any HTTP metrics have been defined
33 | #
34 | # @return [Boolean] if HTTP metrics have been defined
35 | def http_metrics? ; @http_metrics ; end
36 |
37 | private
38 | # Delegate methods to UnicornMetrics::Registry
39 | #
40 | # http://robots.thoughtbot.com/post/28335346416/always-define-respond-to-missing-when-overriding
41 | def respond_to_missing?(method_name, include_private=false)
42 | registry.respond_to?(method_name, include_private)
43 | end
44 |
45 | def method_missing(method_name, *args, &block)
46 | return super unless registry.respond_to?(method_name)
47 | registry.send(method_name, *args, &block)
48 | end
49 | end
50 | end
51 |
52 | require 'raindrops'
53 | require 'unicorn_metrics/registry'
54 | require 'unicorn_metrics/version'
55 | require 'unicorn_metrics/counter'
56 | require 'unicorn_metrics/timer'
57 | require 'unicorn_metrics/default_http_metrics'
58 | require 'unicorn_metrics/request_counter'
59 | require 'unicorn_metrics/request_timer'
60 | require 'unicorn_metrics/response_counter'
61 | require 'forwardable'
62 |
63 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/counter.rb:
--------------------------------------------------------------------------------
1 | # UnicornMetrics::Counter is an atomic counter that conveniently wraps the Raindrops::Struct
2 | #
3 | class UnicornMetrics::Counter
4 | extend Forwardable
5 |
6 | attr_reader :name
7 |
8 | class Stats < Raindrops::Struct.new(:value) ; end
9 |
10 | # Delegate getter and setter to @stats
11 | def_instance_delegator :@stats, :value
12 |
13 | # Provide #increment and #decrement by delegating to @stats
14 | def_instance_delegator :@stats, :incr_value, :increment
15 | def_instance_delegator :@stats, :decr_value, :decrement
16 |
17 | # @param name [String] user-defined name
18 | def initialize(name)
19 | @name = name
20 | @stats = Stats.new
21 | end
22 |
23 | # Reset the counter
24 | def reset
25 | @stats.value = 0
26 | end
27 |
28 | def type
29 | "counter"
30 | end
31 |
32 | # @return [Hash] JSON representation of the object
33 | def as_json(*)
34 | {
35 | name => {
36 | "type" => type,
37 | "value" => value
38 | }
39 | }
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/default_http_metrics.rb:
--------------------------------------------------------------------------------
1 | module UnicornMetrics::DefaultHttpMetrics
2 | def register_default_http_counters
3 | [
4 | ["responses.2xx", /[2]\d{2}/], ["responses.3xx", /[3]\d{2}/],
5 | ["responses.4xx", /[4]\d{2}/], ["responses.5xx", /[5]\d{2}/]
6 | ].each { |c| register(:response_counter, *c) }
7 | end
8 |
9 | def register_default_http_timers
10 | [
11 | ['requests.GET', 'GET'], ['requests.POST', 'POST'],
12 | ['requests.DELETE', 'DELETE'], ['requests.HEAD', 'HEAD'],
13 | ['requests.PUT', 'PUT']
14 | ].each { |c| register(:request_timer, *c) }
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/middleware.rb:
--------------------------------------------------------------------------------
1 | # UnicornMetrics::Middleware extends the existing Raindrops::Middleware class
2 | #
3 | require 'unicorn_metrics' unless defined?(UnicornMetrics)
4 | require 'raindrops'
5 | require 'benchmark'
6 |
7 | class UnicornMetrics::Middleware < Raindrops::Middleware
8 |
9 | # @param opts [Hash] options hash
10 | # @option opts [String] :metrics the HTTP endpoint that exposes the application metrics
11 | def initialize(app, opts = {})
12 | @registry = UnicornMetrics::Registry
13 | @metrics_path = opts[:metrics] || "/metrics"
14 | super
15 | end
16 |
17 | def call(env)
18 | return metrics_response if env['PATH_INFO'] == @metrics_path
19 |
20 | response = nil
21 | time = Benchmark.realtime do
22 | response = super
23 | #=> [ status, headers, <#Raindrops::Middleware::Proxy> ]
24 | # Proxy is a wrapper around the response body
25 | end
26 | collect_http_metrics(env, response, time) if UnicornMetrics.http_metrics?
27 | response
28 | end
29 |
30 | private
31 | def metrics_response
32 | body = @registry.as_json.merge(raindrops).to_json
33 |
34 | headers = {
35 | "Content-Type" => "application/json",
36 | "Content-Length" => body.size.to_s,
37 | }
38 | [ 200, headers, [ body ] ]
39 | end
40 |
41 | def collect_http_metrics(env, response, elapsed_time)
42 | method, path = env['REQUEST_METHOD'], env['PATH_INFO']
43 | status = response[0]
44 |
45 | UnicornMetrics::ResponseCounter.notify(status, path)
46 | UnicornMetrics::RequestTimer.notify(method, path, elapsed_time)
47 | end
48 |
49 | # Provide Raindrops::Middleware statistics in the metrics JSON response
50 | # `@stats` is defined in the Raindrops::Middleware class
51 |
52 | # * calling - the number of application dispatchers on your machine
53 | # * writing - the number of clients being written to on your machine
54 | def raindrops
55 | {
56 | "raindrops.calling" => {
57 | "type" => "gauge",
58 | "value" => @stats.calling
59 | },
60 | "raindrops.writing" => {
61 | "type" => "gauge",
62 | "value" => @stats.writing
63 | }
64 | }.merge(total_listener_stats)
65 | end
66 |
67 | # Supporting additional stats collected by Raindrops for Linux platforms
68 | # `@tcp` and `@unix` are defined in Raindrops::Middleware
69 | def total_listener_stats(listeners={})
70 | if defined?(Raindrops::Linux.tcp_listener_stats)
71 | listeners.merge!(raindrops_tcp_listener_stats) if @tcp
72 | listeners.merge!(raindrops_unix_listener_stats) if @unix
73 | end
74 | listeners
75 | end
76 |
77 | def raindrops_tcp_listener_stats
78 | hash = {
79 | "raindrops.tcp.active" => { type: :gauge, value: 0 },
80 | "raindrops.tcp.queued" => { type: :gauge, value: 0 }
81 | }
82 | Raindrops::Linux.tcp_listener_stats(@tcp).each do |_, stats|
83 | hash["raindrops.tcp.active"][:value] += stats.active.to_i
84 | hash["raindrops.tcp.queued"][:value] += stats.queued.to_i
85 | end
86 | hash
87 | end
88 |
89 | def raindrops_unix_listener_stats
90 | hash = {
91 | "raindrops.unix.active" => { type: :gauge, value: 0 },
92 | "raindrops.unix.queued" => { type: :gauge, value: 0 }
93 | }
94 | Raindrops::Linux.unix_listener_stats(@unix).each do |_, stats|
95 | hash["raindrops.unix.active"][:value] += stats.active.to_i
96 | hash["raindrops.unix.queued"][:value] += stats.queued.to_i
97 | end
98 | hash
99 | end
100 |
101 | # NOTE: The 'total' is being used in favor of returning stats for \
102 | # each listening address, which was the default in Raindrops
103 | def listener_stats(listeners={})
104 | if defined?(Raindrops::Linux.tcp_listener_stats)
105 | Raindrops::Linux.tcp_listener_stats(@tcp).each do |addr,stats|
106 | listeners["raindrops.#{addr}.active"] = "#{stats.active}"
107 | listeners["raindrops.#{addr}.queued"] = "#{stats.queued}"
108 | end if @tcp
109 | Raindrops::Linux.unix_listener_stats(@unix).each do |addr,stats|
110 | listeners["raindrops.#{addr}.active"] = "#{stats.active}"
111 | listeners["raindrops.#{addr}.queued"] = "#{stats.queued}"
112 | end if @unix
113 | end
114 | listeners
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/registry.rb:
--------------------------------------------------------------------------------
1 | # Borrowing nomenclature from http://metrics.codahale.com/
2 | #
3 | # To support a cleaner interface, the UnicornMetrics module delegates to the Registry
4 | # for supported methods. Methods should not be called directly on this module
5 |
6 | # UnicornMetrics::Registry is a container for Metrics
7 | # @private
8 | module UnicornMetrics::Registry
9 |
10 | # Map metrics types to class names
11 | METRIC_TYPES = {
12 | :counter => 'Counter',
13 | :timer => 'Timer',
14 | :response_counter => 'ResponseCounter',
15 | :request_counter => 'RequestCounter',
16 | :request_timer => 'RequestTimer'
17 | }
18 |
19 | class << self
20 |
21 | # Return a hash of metrics that have been defined
22 | #
23 | # @return [Hash] a metric name to metric object
24 | def metrics
25 | @metrics ||= {}
26 | end
27 |
28 | # Register a new metric. Arguments are optional. See metric class definitions.
29 | #
30 | # @param type [Symbol] underscored metric name
31 | # @param name [String] string representing the metric name
32 | # @return [Counter, Timer, ResponseCounter, RequestCounter, RequestTimer]
33 | def register(type, name, *args)
34 | type = METRIC_TYPES.fetch(type) { raise "Invalid type: #{type}" }
35 | validate_name!(name)
36 | metric = UnicornMetrics.const_get(type).new(name,*args)
37 | metrics[name] = metric
38 | define_getter(name)
39 |
40 | metric
41 | end
42 |
43 | # @return [Hash] default JSON representation of metrics
44 | def as_json(*)
45 | metrics.inject({}) do |hash, (name, metric)|
46 | hash.merge(metric.as_json)
47 | end
48 | end
49 |
50 | private
51 | # Convenience methods to return the stored metrics.
52 | # Allows the use of names with spaces, dots, and dashes, which are \
53 | # replaced by an underscore:
54 | #
55 | # def UnicornMetrics::Registry.stat_name
56 | # metrics.fetch('stat_name')
57 | # end
58 | #
59 | def define_getter(name)
60 | define_singleton_method(format_name(name)) { metrics.fetch(name) }
61 | end
62 |
63 | # Replace non-word characters with '_'
64 | def format_name(name)
65 | name.gsub(/\W/, '_')
66 | end
67 |
68 | # @raise [ArgumentError] if the metric name is in use
69 | def validate_name!(name)
70 | if metrics.fetch(name,false)
71 | raise ArgumentError, "The name, '#{name}', is in use."
72 | end
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/request_counter.rb:
--------------------------------------------------------------------------------
1 | # Counter defined to keep count of method types of http requests
2 | # Requires the UnicornMetrics::Middleware
3 |
4 | class UnicornMetrics::RequestCounter < UnicornMetrics::Counter
5 | attr_reader :path, :method_name
6 |
7 | METHOD_COUNTERS = []
8 |
9 | # @param name [String] user-defined name
10 | # @param method_name [String] name of the HTTP method
11 | # @param path [Regex] optional regex that is used to match to a specific URI
12 | def initialize(name, method_name, path=nil)
13 | @path = path
14 | @method_name = method_name.to_s.upcase
15 | METHOD_COUNTERS << self
16 | super(name)
17 | end
18 |
19 | # @return [Array]
20 | def self.counters ; METHOD_COUNTERS ; end
21 |
22 | # @param meth_val [String] is the HTTP method of the request
23 | # @param path [String] is the URI of the request
24 | def self.notify(meth_val, path)
25 | counters.each { |c| c.increment if c.path_method_match?(meth_val, path) }
26 | end
27 |
28 | # @param (see #notify)
29 | # @return [Boolean]
30 | def path_method_match?(meth_val, path_val)
31 | path_matches?(path_val) && method_matches?(meth_val)
32 | end
33 |
34 | private
35 | def path_matches?(val)
36 | !!(path =~ val) || path.nil?
37 | end
38 |
39 | def method_matches?(val)
40 | method_name.upcase == val.to_s
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/request_timer.rb:
--------------------------------------------------------------------------------
1 | # Timer defined to keep track of total elapsed request time
2 | # Requires the UnicornMetrics::Middleware
3 |
4 | class UnicornMetrics::RequestTimer < UnicornMetrics::Timer
5 | attr_reader :path, :method_name
6 |
7 | REQUEST_TIMERS = []
8 |
9 | # @param name [String] user-defined name
10 | # @param method_name [String] name of the HTTP method
11 | # @param path [Regex] optional regex that is used to match to a specific URI
12 | def initialize(name, method_name, path=nil)
13 | @path = path
14 | @method_name = method_name.to_s
15 | REQUEST_TIMERS << self
16 | super(name)
17 | end
18 |
19 | # @return [Array]
20 | def self.timers ; REQUEST_TIMERS ; end
21 |
22 | # @param meth_val [String] is the HTTP method of the request
23 | # @param path [String] is the URI of the request
24 | def self.notify(meth_val, path, elapsed_time)
25 | timers.each { |c| c.tick(elapsed_time) if c.path_method_match?(meth_val, path) }
26 | end
27 |
28 |
29 | # @param (see #notify)
30 | # @return [Boolean]
31 | def path_method_match?(meth_val, path_val)
32 | path_matches?(path_val) && method_matches?(meth_val)
33 | end
34 |
35 | private
36 | def path_matches?(val)
37 | !!(path =~ val) || path.nil?
38 | end
39 |
40 | def method_matches?(val)
41 | method_name.upcase == val.to_s
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/response_counter.rb:
--------------------------------------------------------------------------------
1 | # Counter defined to keep count of status codes of http responses
2 | # Requires the UnicornMetrics::Middleware
3 |
4 | class UnicornMetrics::ResponseCounter < UnicornMetrics::Counter
5 | attr_reader :path, :status_code
6 |
7 | STATUS_COUNTERS = []
8 |
9 | # @param name [String] user-defined name
10 | # @param status_code [Regex] the HTTP status code (e.g., `/[2]\d{2}/`)
11 | # @param path [Regex] optional regex that is used to match to a specific URI
12 | def initialize(name, status_code, path=nil)
13 | @path = path
14 | @status_code = status_code
15 | STATUS_COUNTERS << self
16 | super(name)
17 | end
18 |
19 | # @return [Array]
20 | def self.counters ; STATUS_COUNTERS ; end
21 |
22 |
23 | # @param status [String] is the HTTP status code of the request
24 | # @param path [String] is the URI of the request
25 | def self.notify(status, path)
26 | counters.each { |c| c.increment if c.path_status_match?(status, path) }
27 | end
28 |
29 | # @param (see #notify)
30 | # @return [Boolean]
31 | def path_status_match?(status,path)
32 | status_matches?(status) && path_matches?(path)
33 | end
34 |
35 | private
36 | def path_matches?(val)
37 | path.nil? || !!(path =~ val)
38 | end
39 |
40 | def status_matches?(val)
41 | !!(status_code =~ val.to_s)
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/timer.rb:
--------------------------------------------------------------------------------
1 | # UnicornMetrics::Timer keeps track of total time and the count of 'ticks'
2 | # A simple rate of average of ticks over time elapsed can be calculated this way.
3 | # For more advanced metrics (e.g., 1/5/15min moving averages) this data should be reported to an intelligent metric store (i.e. Graphite)
4 | #
5 | class UnicornMetrics::Timer
6 | extend Forwardable
7 |
8 | attr_reader :name
9 |
10 | # The Raindrops::Struct can only hold unsigned long ints (0 -> 4,294,967,295)
11 | # Since we usually care about ms in a web application, \
12 | # let's store 3 significant digits after the decimal
13 | EXPONENT = -3
14 |
15 | class Stats < Raindrops::Struct.new(:count, :mantissa) ; end
16 |
17 | def_instance_delegators :@stats, :mantissa, :count
18 |
19 | # @param name [String] user-defined name
20 | def initialize(name)
21 | @name = name
22 | @stats = Stats.new
23 | end
24 |
25 | def type
26 | "timer"
27 | end
28 |
29 | # @param elapsed_time [Numeric] in seconds
30 | def tick(elapsed_time)
31 | elapsed_time = (elapsed_time * 10**-EXPONENT).to_i
32 |
33 | @stats.mantissa = mantissa + elapsed_time
34 | @stats.incr_count
35 | end
36 |
37 | # Reset the timer
38 | def reset
39 | @stats.mantissa = 0 and @stats.count = 0
40 | end
41 |
42 | # @return [Numeric] total elapsed time
43 | def sum
44 | (mantissa * 10**EXPONENT).to_f.round(-EXPONENT)
45 | end
46 |
47 | # @return [Hash] JSON representation of the object
48 | def as_json(*)
49 | {
50 | name => {
51 | "type" => type,
52 | "sum" => sum,
53 | "value" => count
54 | }
55 | }
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/unicorn_metrics/version.rb:
--------------------------------------------------------------------------------
1 | module UnicornMetrics
2 | VERSION = "0.2.1"
3 | end
4 |
--------------------------------------------------------------------------------
/test/test_counter.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | describe UnicornMetrics::Counter do
4 | before do
5 | @counter = UnicornMetrics::Counter.new("test_counter")
6 | @counter.reset
7 | end
8 |
9 | describe "#type" do
10 | it "returns 'counter'" do
11 | @counter.type.must_equal 'counter'
12 | end
13 | end
14 |
15 | describe "#value" do
16 | it "returns the internal count" do
17 | @counter.value.must_equal 0
18 | end
19 | end
20 |
21 | describe "#increment" do
22 | it "increments the counter value" do
23 | 5.times { @counter.increment }
24 | @counter.value.must_equal 5
25 | end
26 | end
27 |
28 | describe "#decrement" do
29 | it "decrements the counter value" do
30 | 5.times { @counter.increment }
31 | 5.times { @counter.decrement }
32 | @counter.value.must_equal 0
33 | end
34 | end
35 |
36 | describe "#reset" do
37 | it "resets the counter value" do
38 | 5.times { @counter.increment }
39 | @counter.reset
40 | @counter.value.must_equal 0
41 | end
42 | end
43 |
44 | describe "#as_json" do
45 | it "returns the JSON representation of the object as a hash" do
46 | hash = {
47 | @counter.name => {
48 | "type" => @counter.type,
49 | "value" => @counter.value
50 | }
51 | }
52 |
53 | @counter.as_json.must_equal hash
54 | end
55 | end
56 |
57 | # REFACTOR: This test is very slow
58 | describe "forking" do
59 | it "can be shared across processes" do
60 | 2.times { fork { @counter.increment ; exit } }
61 | Process.waitall
62 | @counter.value.must_equal 2
63 | end
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | require 'minitest/spec'
2 | require 'minitest/autorun'
3 | require 'unicorn_metrics'
4 |
5 | alias :context :describe
6 |
--------------------------------------------------------------------------------
/test/test_middleware.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 | require 'unicorn_metrics/middleware'
3 | require 'json'
4 |
5 | # Stubbing Raindrops::Linux to support testing listener statistics
6 | # See Raindrops::Middleware and Raindrops::Linux
7 | module Raindrops::Linux
8 | Stats = Struct.new(:active, :queued)
9 | def self.tcp_listener_stats(*) ; [['123', Stats.new(1,5)]] ; end
10 | def self.unix_listener_stats(*) ; [['456', Stats.new(1,5)]] ; end
11 | end
12 |
13 | describe UnicornMetrics::Middleware do
14 | before do
15 | @resp_headers = { 'Content-Type' => 'text/plain', 'Content-Length' => '0' }
16 | @response = [ 200, @resp_headers, ["test_body"] ]
17 | @app = ->(env){ @response }
18 |
19 | # Remove any metrics lingering from previous tests
20 | UnicornMetrics.metrics.delete_if{true}
21 |
22 | @counter = UnicornMetrics.register(:counter, "test_counter")
23 | options = { metrics: '/metrics', listeners: %w(0.0.0.0:80) }
24 | @middleware = UnicornMetrics::Middleware.new(@app, options)
25 | end
26 |
27 | after { UnicornMetrics.metrics.delete("test_counter")}
28 |
29 | describe "#call" do
30 | context "when path matches the defined metrics path" do
31 | before do
32 | response = @middleware.call({'PATH_INFO' => '/metrics'})
33 | @hash = JSON response[2][0]
34 | end
35 |
36 | it "returns the metrics response JSON body" do
37 | @hash.fetch("test_counter").must_equal @counter.as_json.fetch("test_counter")
38 | end
39 |
40 | it "includes raindrops middleware metrics" do
41 | @hash.must_include "raindrops.calling"
42 | @hash.must_include "raindrops.writing"
43 | @hash.must_include "raindrops.tcp.active"
44 | @hash.must_include "raindrops.tcp.queued"
45 | end
46 | end
47 |
48 | context "when the path does not match the defined metrics path" do
49 | it "returns the expected response" do
50 | response = @middleware.call({'PATH_INFO' => '/'})
51 |
52 | # The Raindrops::Middleware wraps the response body in a Proxy
53 | # Write the response body to a string to match the expectation
54 | response[2] = [ response[2].inject(""){ |str, v| str << v } ]
55 |
56 | response.must_equal @response
57 | end
58 | end
59 | end
60 |
61 | end
62 |
--------------------------------------------------------------------------------
/test/test_registry.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | describe UnicornMetrics::Registry do
4 | describe "METRIC_TYPES" do
5 | it "returns a hash that maps type symbols to class names" do
6 | hash = {
7 | :counter => 'Counter',
8 | :timer => 'Timer',
9 | :response_counter => 'ResponseCounter',
10 | :request_counter => 'RequestCounter',
11 | :request_timer => 'RequestTimer'
12 | }
13 | UnicornMetrics::Registry::METRIC_TYPES.must_equal hash
14 | end
15 | end
16 |
17 | describe ".register" do
18 | before { UnicornMetrics.register(:counter,"test-counter") }
19 | after { UnicornMetrics.metrics.delete("test-counter")}
20 |
21 | it "initializes and stores a new metric object" do
22 | UnicornMetrics.metrics.fetch("test-counter").must_be_instance_of UnicornMetrics::Counter
23 | end
24 |
25 | it "defines getter method from the name of the metric with non-word chars replaced by '_'" do
26 | UnicornMetrics.metrics.fetch("test-counter").must_be_same_as UnicornMetrics.test_counter
27 | end
28 |
29 | it "raises an error if a name is used twice" do
30 | ->{UnicornMetrics.register(:counter, "test-counter")}.must_raise ArgumentError
31 | end
32 | end
33 | end
34 |
35 |
--------------------------------------------------------------------------------
/test/test_request_counter.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | describe UnicornMetrics::RequestCounter do
4 | before do
5 | @counter = UnicornMetrics::RequestCounter.new("test_counter", 'POST')
6 | @counter.reset
7 | end
8 |
9 | describe ".counters" do
10 | it "returns a collection of current RequestCounter instances" do
11 | UnicornMetrics::RequestCounter.counters.must_include @counter
12 | end
13 | end
14 |
15 | describe ".notify" do
16 | it "increments all existing counters that match an http method and path" do
17 | UnicornMetrics::RequestCounter.notify('POST','/')
18 | @counter.value.must_equal 1
19 | end
20 | end
21 |
22 | describe "#path_method_match?" do
23 | context "when path is nil (not specified)" do
24 | context "when method name matches" do
25 | it "returns true" do
26 | @counter.path_method_match?('POST', '/anything').must_equal true
27 | end
28 | end
29 |
30 | context "when method name does not match" do
31 | it "returns false" do
32 | @counter.path_method_match?('GET', '/anything').must_equal false
33 | end
34 | end
35 | end
36 |
37 | context "when path is not nil (it is set)" do
38 | before { @counter.instance_variable_set(:@path, /\/something/) }
39 | after { @counter.instance_variable_set(:@path, nil) }
40 |
41 | context "when method matches" do
42 | context "when patch matches" do
43 | it "returns true" do
44 | @counter.path_method_match?('POST', '/something').must_equal true
45 | end
46 | end
47 |
48 | context "when patch does not match" do
49 | it "returns false" do
50 | @counter.path_method_match?('POST', '/bla').must_equal false
51 | end
52 | end
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/test/test_request_timer.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | describe UnicornMetrics::RequestTimer do
4 | before do
5 | @timer = UnicornMetrics::RequestTimer.new("test_timer", 'POST')
6 | @timer.reset
7 | end
8 |
9 | describe ".timers" do
10 | it "returns a collection of current RequestTimer instances" do
11 | UnicornMetrics::RequestTimer.timers.must_include @timer
12 | end
13 | end
14 |
15 | describe ".notify" do
16 | it "ticks all existing timers that match an http method and path" do
17 | UnicornMetrics::RequestTimer.notify('POST','/', 10.0)
18 | @timer.sum.must_equal 10.0
19 | end
20 | end
21 |
22 | describe "#path_method_match?" do
23 | context "when path is nil (not specified)" do
24 | context "when method name matches" do
25 | it "returns true" do
26 | @timer.path_method_match?('POST', '/anything').must_equal true
27 | end
28 | end
29 |
30 | context "when method name does not match" do
31 | it "returns false" do
32 | @timer.path_method_match?('GET', '/anything').must_equal false
33 | end
34 | end
35 | end
36 |
37 | context "when path is not nil (it is set)" do
38 | before { @timer.instance_variable_set(:@path, /\/something/) }
39 | after { @timer.instance_variable_set(:@path, nil) }
40 |
41 | context "when method matches" do
42 | context "when patch matches" do
43 | it "returns true" do
44 | @timer.path_method_match?('POST', '/something').must_equal true
45 | end
46 | end
47 |
48 | context "when patch does not match" do
49 | it "returns false" do
50 | @timer.path_method_match?('POST', '/bla').must_equal false
51 | end
52 | end
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/test/test_response_counter.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | describe UnicornMetrics::ResponseCounter do
4 | before do
5 | @counter = UnicornMetrics::ResponseCounter.new("test_counter", /[2]\d{2}/)
6 | @counter.reset
7 | end
8 |
9 | describe ".counters" do
10 | it "returns a collection of current ResponseCounter instances" do
11 | UnicornMetrics::ResponseCounter.counters.must_include @counter
12 | end
13 | end
14 |
15 | describe ".notify" do
16 | it "increments all existing counters that match a status code and path" do
17 | UnicornMetrics::ResponseCounter.notify('200','/')
18 | @counter.value.must_equal 1
19 | end
20 | end
21 |
22 | describe "#path_status_match?" do
23 | context "when path is nil (not specified)" do
24 | context "when status name matches" do
25 | it "returns true" do
26 | @counter.path_status_match?('200', '/anything').must_equal true
27 | end
28 | end
29 |
30 | context "when status name does not match" do
31 | it "returns false" do
32 | @counter.path_status_match?('400', '/anything').must_equal false
33 | end
34 | end
35 | end
36 |
37 | context "when path is not nil (it is set)" do
38 | before { @counter.instance_variable_set(:@path, /\/something/) }
39 | after { @counter.instance_variable_set(:@path, nil) }
40 |
41 | context "when status matches" do
42 | context "when patch matches" do
43 | it "returns true" do
44 | @counter.path_status_match?('200', '/something').must_equal true
45 | end
46 | end
47 |
48 | context "when patch does not match" do
49 | it "returns false" do
50 | @counter.path_status_match?('200', '/bla').must_equal false
51 | end
52 | end
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/test/test_timer.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | describe UnicornMetrics::Timer do
4 | before do
5 | @timer = UnicornMetrics::Timer.new("test_timer")
6 | @timer.reset
7 | end
8 |
9 | describe "#type" do
10 | it "returns 'timer'" do
11 | @timer.type.must_equal 'timer'
12 | end
13 | end
14 |
15 | context "when initialized" do
16 | describe "#sum" do
17 | it "must be zero" do
18 | @timer.sum.must_equal 0.0
19 | end
20 | end
21 |
22 | describe "#count" do
23 | it "must be zero" do
24 | @timer.count.must_equal 0
25 | end
26 | end
27 | end
28 |
29 | context "when ticked" do
30 | describe "#sum" do
31 | it "returns sum + elapsed time" do
32 | @timer.tick(5)
33 | @timer.sum.must_equal 5.0
34 | end
35 | end
36 |
37 | describe "#count" do
38 | it "returns the count of ticks" do
39 | @timer.tick(5)
40 | @timer.count.must_equal 1
41 | end
42 | end
43 | end
44 |
45 | describe "#reset" do
46 | it "resets count and sum" do
47 | 5.times { @timer.tick(5) }
48 | @timer.reset
49 | @timer.sum.must_equal 0
50 | @timer.count.must_equal 0
51 | end
52 | end
53 |
54 | describe "#as_json" do
55 | it "returns the JSON representation of the object as a hash" do
56 | hash = {
57 | @timer.name => {
58 | "type" => @timer.type,
59 | "sum" => @timer.sum,
60 | "value" => @timer.count
61 | }
62 | }
63 |
64 | @timer.as_json.must_equal hash
65 | end
66 | end
67 |
68 | describe "forking" do
69 | it "can be shared across processes" do
70 | 2.times { fork { @timer.tick(5) ; exit } }
71 | Process.waitall
72 | @timer.sum.must_equal 10.0
73 | @timer.count.must_equal 2
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/test/test_unicorn_metrics.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | describe UnicornMetrics do
4 | describe "::registry" do
5 | it "returns the UnicornMetrics::Registry object" do
6 | UnicornMetrics.registry.must_equal UnicornMetrics::Registry
7 | end
8 | end
9 |
10 | describe "::configure" do
11 | it "yields self" do
12 | ->{ UnicornMetrics.configure {|u| print u}}.must_output 'UnicornMetrics'
13 | end
14 | end
15 |
16 | describe "::http_metrics=" do
17 | context "when arg is false" do
18 | it "should not extend Registry with DefaultHttpCounters module" do
19 | UnicornMetrics.registry.wont_respond_to :register_default_http_counters
20 | end
21 | end
22 |
23 | context "when arg is true" do
24 | before { UnicornMetrics.http_metrics = true }
25 |
26 | it "extends Registry with DefaultHttpMetrics module" do
27 | UnicornMetrics.registry.must_respond_to :register_default_http_counters
28 | UnicornMetrics.registry.must_respond_to :register_default_http_timers
29 | end
30 |
31 | it "registers the default http counters" do
32 | UnicornMetrics.registry.metrics.keys.size.must_equal 9
33 | end
34 | end
35 | end
36 |
37 | it "delegates unknown methods to Registry" do
38 | methods = UnicornMetrics.registry.methods(false)
39 | respond_count = 0
40 | methods.each { |m| respond_count+=1 if UnicornMetrics.respond_to?(m) }
41 | respond_count.must_equal methods.size
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/unicorn_metrics.gemspec:
--------------------------------------------------------------------------------
1 | # -*- encoding: utf-8 -*-
2 | lib = File.expand_path('../lib', __FILE__)
3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4 | require 'unicorn_metrics/version'
5 |
6 | Gem::Specification.new do |spec|
7 | spec.name = "unicorn_metrics"
8 | spec.version = UnicornMetrics::VERSION
9 | spec.authors = ["Alan Cohen"]
10 | spec.email = ["acohen@climate.com"]
11 | spec.summary = %q{Metrics library for Rack applications using a preforking http server (i.e., Unicorn) }
12 | spec.homepage = "http://www.climate.com"
13 | spec.files = Dir['lib/**/*.rb'] + Dir['test/*']
14 | spec.executables = spec.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
15 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
16 | spec.require_paths = ["lib"]
17 | spec.add_development_dependency('rake', '~> 12.3.3')
18 | spec.add_dependency('raindrops', '~> 0.11.0')
19 | spec.requirements << 'Preforking http server (i.e., Unicorn).'
20 | spec.required_ruby_version = '>= 1.9.3'
21 | end
22 |
--------------------------------------------------------------------------------