diff --git a/Gemfile b/Gemfile index 0b53433..50e1b75 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,7 @@ source 'https://rubygems.org/' -gemspec +gemspec name: 'multimeter' +gemspec name: 'multimeter-http' group :examples do gem 'rack' diff --git a/Rakefile b/Rakefile index 4a76554..bf79ba7 100644 --- a/Rakefile +++ b/Rakefile @@ -14,7 +14,7 @@ Rake::JavaExtensionTask.new('multimeter_metrics') do |ext| end namespace :bundler do - Bundler::GemHelper.install_tasks + Bundler::GemHelper.install_tasks(name: 'multimeter') end task :release => [:spec, :compile, 'bundler:release'] diff --git a/ext/java/MultimeterMetricsService.java b/ext/java/MultimeterMetricsService.java index 7c53adf..808dc72 100644 --- a/ext/java/MultimeterMetricsService.java +++ b/ext/java/MultimeterMetricsService.java @@ -10,6 +10,7 @@ import multimeter.Histogram; import multimeter.Snapshot; import multimeter.Gauge; +import multimeter.JSONSerializer; public class MultimeterMetricsService implements BasicLibraryService { public boolean basicLoad(final Ruby runtime) throws IOException { @@ -20,6 +21,7 @@ public boolean basicLoad(final Ruby runtime) throws IOException { Histogram.setup(runtime); Snapshot.setup(runtime); Gauge.setup(runtime); + JSONSerializer.setup(runtime); return true; } } diff --git a/ext/java/multimeter/Counter.java b/ext/java/multimeter/Counter.java index 1339b7d..d819ab4 100644 --- a/ext/java/multimeter/Counter.java +++ b/ext/java/multimeter/Counter.java @@ -6,12 +6,14 @@ import org.jruby.RubyObject; import org.jruby.RubyHash; import org.jruby.RubyArray; +import org.jruby.RubyString; import org.jruby.anno.JRubyClass; import org.jruby.anno.JRubyMethod; import org.jruby.runtime.Block; import org.jruby.runtime.ObjectAllocator; import org.jruby.runtime.ThreadContext; import org.jruby.runtime.builtin.IRubyObject; +import org.jruby.javasupport.JavaUtil; import static org.jruby.runtime.Visibility.PRIVATE; @@ -30,6 +32,16 @@ public Counter(Ruby runtime, com.codahale.metrics.Counter counter) { this.counter = counter; } + @JRubyMethod(name="to_java") + public IRubyObject toJava(ThreadContext ctx) { + return JavaUtil.convertJavaToUsableRubyObject(ctx.runtime, counter); + } + + @JRubyMethod(name="to_json") + public RubyString toJson(ThreadContext ctx) { + return JSONSerializer.getInstance().serialize(ctx, counter); + } + @JRubyMethod public IRubyObject count(ThreadContext ctx) { return ctx.runtime.newFixnum(counter.getCount()); diff --git a/ext/java/multimeter/Gauge.java b/ext/java/multimeter/Gauge.java index 85167e9..f2402e4 100644 --- a/ext/java/multimeter/Gauge.java +++ b/ext/java/multimeter/Gauge.java @@ -6,6 +6,7 @@ import org.jruby.RubyObject; import org.jruby.RubyHash; import org.jruby.RubyArray; +import org.jruby.RubyString; import org.jruby.anno.JRubyClass; import org.jruby.anno.JRubyMethod; import org.jruby.runtime.Block; @@ -31,6 +32,16 @@ public Gauge(Ruby runtime, com.codahale.metrics.Gauge gauge) { this.gauge = gauge; } + @JRubyMethod(name="to_java") + public IRubyObject toJava(ThreadContext ctx) { + return JavaUtil.convertJavaToUsableRubyObject(ctx.runtime, gauge); + } + + @JRubyMethod(name="to_json") + public RubyString toJson(ThreadContext ctx) { + return JSONSerializer.getInstance().serialize(ctx, gauge); + } + @JRubyMethod public IRubyObject value(ThreadContext ctx) { Object value = gauge.getValue(); diff --git a/ext/java/multimeter/Histogram.java b/ext/java/multimeter/Histogram.java index fcc913d..0d554b3 100644 --- a/ext/java/multimeter/Histogram.java +++ b/ext/java/multimeter/Histogram.java @@ -6,12 +6,14 @@ import org.jruby.RubyObject; import org.jruby.RubyHash; import org.jruby.RubyArray; +import org.jruby.RubyString; import org.jruby.anno.JRubyClass; import org.jruby.anno.JRubyMethod; import org.jruby.runtime.Block; import org.jruby.runtime.ObjectAllocator; import org.jruby.runtime.ThreadContext; import org.jruby.runtime.builtin.IRubyObject; +import org.jruby.javasupport.JavaUtil; import static org.jruby.runtime.Visibility.PRIVATE; @@ -30,6 +32,16 @@ public Histogram(Ruby runtime, com.codahale.metrics.Histogram histogram) { this.histogram = histogram; } + @JRubyMethod(name="to_java") + public IRubyObject toJava(ThreadContext ctx) { + return JavaUtil.convertJavaToUsableRubyObject(ctx.runtime, histogram); + } + + @JRubyMethod(name="to_json") + public RubyString toJson(ThreadContext ctx) { + return JSONSerializer.getInstance().serialize(ctx, histogram); + } + @JRubyMethod public IRubyObject count(ThreadContext ctx) { return ctx.runtime.newFixnum(histogram.getCount()); diff --git a/ext/java/multimeter/JSONSerializer.java b/ext/java/multimeter/JSONSerializer.java new file mode 100644 index 0000000..3e53849 --- /dev/null +++ b/ext/java/multimeter/JSONSerializer.java @@ -0,0 +1,55 @@ +package multimeter; + +import com.codahale.metrics.json.MetricsModule; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import org.jruby.Ruby; +import org.jruby.RubyClass; +import org.jruby.RubyException; +import org.jruby.exceptions.RaiseException; +import org.jruby.RubyModule; +import org.jruby.RubyString; +import org.jruby.anno.JRubyClass; +import org.jruby.runtime.ThreadContext; + +public class JSONSerializer { + private ObjectMapper mapper; + private MetricsModule module; + + public static void setup(Ruby runtime) { + RubyModule multimeterModule = runtime.defineModule("Multimeter"); + RubyClass standardError = runtime.getStandardError(); + multimeterModule.defineClassUnder("JSONError", standardError, standardError.getAllocator()); + } + + @JRubyClass(name="Multimeter::JSONError", parent="StandardError") + public static class JSONError {} + + private static JSONSerializer instance; + + public static JSONSerializer getInstance() { + if ( instance == null ) { + instance = new JSONSerializer(); + } + return instance; + } + + private JSONSerializer() { + this.mapper = new ObjectMapper(); + this.module = new MetricsModule(TimeUnit.SECONDS, TimeUnit.MILLISECONDS, false); + mapper.registerModule(module); + } + + public RubyString serialize(ThreadContext ctx, Object object) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + try { + mapper.writeValue(stream, object); + return RubyString.newString(ctx.runtime, stream.toByteArray()); + } catch (IOException e) { + RubyClass jsonError = ctx.runtime.getModule("Multimeter").getClass("JSONError"); + throw new RaiseException(RubyException.newException(ctx.runtime, jsonError, e.toString()), false); + } + } +} \ No newline at end of file diff --git a/ext/java/multimeter/Meter.java b/ext/java/multimeter/Meter.java index 1fff410..2290527 100644 --- a/ext/java/multimeter/Meter.java +++ b/ext/java/multimeter/Meter.java @@ -6,12 +6,14 @@ import org.jruby.RubyObject; import org.jruby.RubyHash; import org.jruby.RubyArray; +import org.jruby.RubyString; import org.jruby.anno.JRubyClass; import org.jruby.anno.JRubyMethod; import org.jruby.runtime.Block; import org.jruby.runtime.ObjectAllocator; import org.jruby.runtime.ThreadContext; import org.jruby.runtime.builtin.IRubyObject; +import org.jruby.javasupport.JavaUtil; import static org.jruby.runtime.Visibility.PRIVATE; @@ -30,6 +32,16 @@ public Meter(Ruby runtime, com.codahale.metrics.Meter meter) { this.meter = meter; } + @JRubyMethod(name="to_java") + public IRubyObject toJava(ThreadContext ctx) { + return JavaUtil.convertJavaToUsableRubyObject(ctx.runtime, meter); + } + + @JRubyMethod(name="to_json") + public RubyString toJson(ThreadContext ctx) { + return JSONSerializer.getInstance().serialize(ctx, meter); + } + @JRubyMethod public IRubyObject count(ThreadContext ctx) { return ctx.runtime.newFixnum(meter.getCount()); diff --git a/ext/java/multimeter/MetricRegistry.java b/ext/java/multimeter/MetricRegistry.java index bd8fbc8..b042f72 100644 --- a/ext/java/multimeter/MetricRegistry.java +++ b/ext/java/multimeter/MetricRegistry.java @@ -49,6 +49,11 @@ public IRubyObject toJava(ThreadContext ctx) { return JavaUtil.convertJavaToUsableRubyObject(ctx.runtime, registry); } + @JRubyMethod(name="to_json") + public RubyString toJson(ThreadContext ctx) { + return JSONSerializer.getInstance().serialize(ctx, registry); + } + @JRubyMethod public IRubyObject metrics(ThreadContext ctx) { return metrics.dup(ctx); diff --git a/ext/java/multimeter/Snapshot.java b/ext/java/multimeter/Snapshot.java index f3ba09c..f68c30e 100644 --- a/ext/java/multimeter/Snapshot.java +++ b/ext/java/multimeter/Snapshot.java @@ -6,12 +6,14 @@ import org.jruby.RubyObject; import org.jruby.RubyHash; import org.jruby.RubyArray; +import org.jruby.RubyString; import org.jruby.anno.JRubyClass; import org.jruby.anno.JRubyMethod; import org.jruby.runtime.Block; import org.jruby.runtime.ObjectAllocator; import org.jruby.runtime.ThreadContext; import org.jruby.runtime.builtin.IRubyObject; +import org.jruby.javasupport.JavaUtil; import static org.jruby.runtime.Visibility.PRIVATE; @@ -30,6 +32,16 @@ public Snapshot(Ruby runtime, com.codahale.metrics.Snapshot snapshot) { this.snapshot = snapshot; } + @JRubyMethod(name="to_java") + public IRubyObject toJava(ThreadContext ctx) { + return JavaUtil.convertJavaToUsableRubyObject(ctx.runtime, snapshot); + } + + @JRubyMethod(name="to_json") + public RubyString toJson(ThreadContext ctx) { + return JSONSerializer.getInstance().serialize(ctx, snapshot); + } + @JRubyMethod public IRubyObject size(ThreadContext ctx) { return ctx.runtime.newFixnum(snapshot.size()); diff --git a/ext/java/multimeter/Timer.java b/ext/java/multimeter/Timer.java index 7b0e177..5b06758 100644 --- a/ext/java/multimeter/Timer.java +++ b/ext/java/multimeter/Timer.java @@ -6,12 +6,14 @@ import org.jruby.RubyObject; import org.jruby.RubyHash; import org.jruby.RubyArray; +import org.jruby.RubyString; import org.jruby.anno.JRubyClass; import org.jruby.anno.JRubyMethod; import org.jruby.runtime.Block; import org.jruby.runtime.ObjectAllocator; import org.jruby.runtime.ThreadContext; import org.jruby.runtime.builtin.IRubyObject; +import org.jruby.javasupport.JavaUtil; import static org.jruby.runtime.Visibility.PRIVATE; @@ -35,6 +37,16 @@ public Timer(Ruby runtime, com.codahale.metrics.Timer timer) { this.timer = timer; } + @JRubyMethod(name="to_java") + public IRubyObject toJava(ThreadContext ctx) { + return JavaUtil.convertJavaToUsableRubyObject(ctx.runtime, timer); + } + + @JRubyMethod(name="to_json") + public RubyString toJson(ThreadContext ctx) { + return JSONSerializer.getInstance().serialize(ctx, timer); + } + @JRubyMethod public IRubyObject count(ThreadContext ctx) { return ctx.runtime.newFixnum(timer.getCount()); diff --git a/lib/multimeter.rb b/lib/multimeter.rb index a40aeb3..28c2a18 100644 --- a/lib/multimeter.rb +++ b/lib/multimeter.rb @@ -1,95 +1,16 @@ # encoding: utf-8 require 'metrics-core-jars' +require 'metrics-json-jars' require 'multimeter_metrics' -require 'json' +require 'multimeter/rack' module Metrics include_package 'com.codahale.metrics' end module Multimeter - class MetricRegistry - def to_h - h = {} - metrics.each do |metric_name, metric| - h[metric_name] = metric.to_h - end - h - end - end - - class Meter - def to_h - { - :type => :meter, - :count => count, - :mean_rate => mean_rate, - :one_minute_rate => one_minute_rate, - :five_minute_rate => five_minute_rate, - :fifteen_minute_rate => fifteen_minute_rate - } - end - end - - class Counter - def to_h - { - :type => :counter, - :count => count - } - end - end - - class Histogram - def to_h - { - :type => :histogram, - :count => count, - }.merge(snapshot.to_h(NANO_TO_MILLI_SCALE)) - end - end - - class Timer - def to_h - { - :type => :timer, - :count => count, - :mean_rate => mean_rate, - :one_minute_rate => one_minute_rate, - :five_minute_rate => five_minute_rate, - :fifteen_minute_rate => fifteen_minute_rate, - }.merge(snapshot.to_h(NANO_TO_MILLI_SCALE)) - end - end - - class Snapshot - def to_h(scale=1) - { - :max => max * scale, - :min => min * scale, - :mean => mean * scale, - :std_dev => std_dev * scale, - :median => median * scale, - :percentiles => { - '75' => p75 * scale, - '95' => p95 * scale, - '98' => p98 * scale, - '99' => p99 * scale, - '99.9' => p999 * scale, - } - } - end - end - - class Gauge - def to_h - { - :type => :gauge, - :value => value, - } - end - end + extend Rack def self.create_registry MetricRegistry.new @@ -98,51 +19,4 @@ def self.create_registry def self.jmx(registry, options = {}) Metrics::JmxReporter.forRegistry(registry.to_java).inDomain(options[:domain] || 'multimeter').build.tap(&:start) end - - def self.http(registry, rack_handler, options={}) - server_thread = Java::JavaLang::Thread.new do - rack_handler.run(Http.create_app(registry), options) - end - server_thread.daemon = true - server_thread.name = 'multimeter-http-server' - server_thread.start - server_thread - end - - private - - NANO_TO_MILLI_SCALE = 1.0/1_000_000 - - module Http - class BadRequest < StandardError; end - - COMMON_HEADERS = {'Connection' => 'close'}.freeze - JSON_HEADERS = COMMON_HEADERS.merge('Content-Type' => 'application/json').freeze - JSONP_HEADERS = COMMON_HEADERS.merge('Content-Type' => 'application/javascript').freeze - ERROR_HEADERS = COMMON_HEADERS.merge('Content-Type' => 'text/plain').freeze - - def self.create_app(registry) - proc do |env| - begin - body = registry.to_h.to_json - headers = JSON_HEADERS - if (callback_name = env['QUERY_STRING'][/callback=([^$&]+)/, 1]) - if callback_name =~ /^[\w\d.]+$/ - body = "#{callback_name}(#{body});" - headers = JSONP_HEADERS - else - raise BadRequest - end - else - headers = headers.merge('Access-Control-Allow-Origin' => '*') - end - [200, headers, [body]] - rescue BadRequest => e - [400, ERROR_HEADERS, ['Bad Request']] - rescue => e - [500, ERROR_HEADERS, ["Internal Server Error\n\n", e.message, "\n\t", *e.backtrace.join("\n\t")]] - end - end - end - end end diff --git a/lib/multimeter/http.rb b/lib/multimeter/http.rb new file mode 100644 index 0000000..601355c --- /dev/null +++ b/lib/multimeter/http.rb @@ -0,0 +1,58 @@ +# encoding: utf-8 + +require 'multimeter' +require 'metrics-servlets-jars' +require 'rjack-jetty' + +module Metrics + module Servlets + include_package 'com.codahale.metrics.servlets' + end +end + +module Jetty + include_package 'org.eclipse.jetty.server' + include_package 'org.eclipse.jetty.servlet' +end + +module Multimeter + module Http + def http(registry, options={}) + server = Jetty::Server.new(options[:port] || 5747) + server.handler = create_servlet_context(registry) + Server.new(server) + end + + private + + def create_servlet_context(registry) + context = Jetty::ServletContextHandler.new(Jetty::ServletContextHandler::SESSIONS) + context.context_path = '/' + context.set_attribute(Metrics::Servlets::MetricsServlet::METRICS_REGISTRY, registry.to_java) + context.add_servlet(Metrics::Servlets::MetricsServlet.java_class, '/*') + context + end + + class Server + def initialize(server) + @server = server + end + + def start + @server.start + self + end + + def stop + @server.stop + self + end + + def join + @server.join + end + end + end + + extend Http +end diff --git a/lib/multimeter/rack.rb b/lib/multimeter/rack.rb new file mode 100644 index 0000000..746311b --- /dev/null +++ b/lib/multimeter/rack.rb @@ -0,0 +1,41 @@ +# encoding: utf-8 + +module Multimeter + module Rack + class BadRequest < StandardError; end + + COMMON_HEADERS = {'Connection' => 'close'}.freeze + JSON_HEADERS = COMMON_HEADERS.merge('Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*').freeze + JSONP_HEADERS = COMMON_HEADERS.merge('Content-Type' => 'application/javascript').freeze + ERROR_HEADERS = COMMON_HEADERS.merge('Content-Type' => 'text/plain').freeze + + class App + def initialize(registry) + @registry = registry + end + + def call(env) + if (callback_name = env['QUERY_STRING'][/callback=([^$&]+)/, 1]) + if callback_name =~ /^[\w\d.]+$/ + body = "#{callback_name}(#{@registry.to_json});" + headers = JSONP_HEADERS + else + raise BadRequest + end + else + headers = JSON_HEADERS + body = @registry.to_json + end + [200, headers, [body]] + rescue BadRequest => e + [400, ERROR_HEADERS, ['Bad Request']] + rescue => e + [500, ERROR_HEADERS, ["Internal Server Error\n\n", e.message, "\n\t", *e.backtrace.join("\n\t")]] + end + end + + def create_app(registry) + App.new(registry) + end + end +end diff --git a/multimeter-http.gemspec b/multimeter-http.gemspec new file mode 100644 index 0000000..811c102 --- /dev/null +++ b/multimeter-http.gemspec @@ -0,0 +1,23 @@ +$: << File.expand_path('../lib', __FILE__) + +require 'rake' +require 'multimeter/version' + +Gem::Specification.new do |s| + s.name = 'multimeter-http' + s.version = Multimeter::VERSION + s.platform = 'java' + s.authors = ['Theo Hultberg', 'Joel Segerlind'] + s.email = ['theo@iconara.net', 'joel.segerlind@gmail.com'] + s.homepage = 'http://github.com/iconara/multimeter' + s.summary = 'Multimeter addition for serving metrics over HTTP' + s.description = 'Multimeter addition for serving metrics over HTTP, using Dropwizard\'s (a.k.a. Coda Hale\'s) Metrics libraries under the hood.' + s.license = 'Apache-2.0' + + s.add_dependency 'multimeter', "= #{Multimeter::VERSION}" + s.add_dependency 'metrics-servlets-jars', '~> 3.1' + s.add_dependency 'rjack-jetty', '~> 9.2.12' + + s.files = Dir['lib/multimeter/http.rb'] + s.require_paths = %w[lib] +end diff --git a/multimeter.gemspec b/multimeter.gemspec index c2e2a73..6d5cf1a 100644 --- a/multimeter.gemspec +++ b/multimeter.gemspec @@ -18,7 +18,8 @@ Gem::Specification.new do |s| s.rubyforge_project = 'multimeter' s.add_dependency 'metrics-core-jars', '~> 3.1', '< 4.0.0' + s.add_dependency 'metrics-json-jars', '~> 3.1' - s.files = Dir['lib/**/*.rb', 'lib/**/*.jar'] + s.files = %w[lib/multimeter.rb lib/multimeter_metrics.jar lib/multimeter/json.rb lib/multimeter/rack.rb] s.require_paths = %w[lib] end diff --git a/spec/integration/http_spec.rb b/spec/integration/http_spec.rb new file mode 100644 index 0000000..f2c566b --- /dev/null +++ b/spec/integration/http_spec.rb @@ -0,0 +1,45 @@ +# encoding: utf-8 + +require 'spec_helper' +require 'open-uri' +require 'support/json_metrics' + +module Multimeter + describe Http do + let :registry do + MetricRegistry.new + end + + let :port do + loop do + port = rand(1024...2**15) + begin + TCPSocket.new('localhost', port).close + rescue Errno::ECONNREFUSED + return port + end + end + end + + let :server do + Multimeter.http(registry, port: port) + end + + before do + server.start + end + + after do + server.stop + server.join + end + + describe 'GET /' do + let :json do + open("http://localhost:#{port}").read + end + + include_examples 'json-metrics' + end + end +end \ No newline at end of file diff --git a/spec/multimeter/counter_spec.rb b/spec/multimeter/counter_spec.rb index ba3ed2e..26e6d3c 100644 --- a/spec/multimeter/counter_spec.rb +++ b/spec/multimeter/counter_spec.rb @@ -34,13 +34,10 @@ module Multimeter end end - describe '#to_h' do - it 'returns a hash representation of the counter' do + describe '#to_json' do + it 'returns a json representation of the counter' do counter.inc - expect(counter.to_h).to eq( - :type => :counter, - :count => 1 - ) + expect(JSON.parse(counter.to_json)).to include('count' => 1) end end end diff --git a/spec/multimeter/gauge_spec.rb b/spec/multimeter/gauge_spec.rb index 3be0e13..b9424da 100644 --- a/spec/multimeter/gauge_spec.rb +++ b/spec/multimeter/gauge_spec.rb @@ -5,7 +5,7 @@ module Multimeter describe Gauge do let :gauge do - MetricRegistry.new.gauge('a_gauge') { @value } + MetricRegistry.new.gauge('a_gauge', :int) { @value } end before do @@ -20,12 +20,19 @@ module Multimeter end end - describe '#to_h' do - it 'returns a hash representation of the gauge' do - expect(gauge.to_h).to eq( - :type => :gauge, - :value => 3 - ) + describe '#to_json' do + it 'returns a json representation of the gauge' do + expect(JSON.parse(gauge.to_json)).to include('value' => 3) + end + + context 'when the gauge is untyped' do + let :gauge do + MetricRegistry.new.gauge('b_gauge') { 1 } + end + + it 'raises JsonError' do + expect { gauge.to_json }.to raise_error(JSONError) + end end end end diff --git a/spec/multimeter/histogram_spec.rb b/spec/multimeter/histogram_spec.rb index 51189ce..7712fc3 100644 --- a/spec/multimeter/histogram_spec.rb +++ b/spec/multimeter/histogram_spec.rb @@ -69,21 +69,22 @@ module Multimeter end end - it 'returns a hash representation of the histogram' do - histogram.update(4) - h = histogram.to_h - expect(h[:type]).to eq(:histogram) - expect(h[:count]).to eq(1) - expect(h[:max]).to be_a(Numeric) - expect(h[:min]).to be_a(Numeric) - expect(h[:mean]).to be_a(Numeric) - expect(h[:std_dev]).to be_a(Numeric) - expect(h[:median]).to be_a(Numeric) - expect(h[:percentiles]['75']).to be_a(Numeric) - expect(h[:percentiles]['95']).to be_a(Numeric) - expect(h[:percentiles]['98']).to be_a(Numeric) - expect(h[:percentiles]['99']).to be_a(Numeric) - expect(h[:percentiles]['99.9']).to be_a(Numeric) + describe '#to_json' do + it 'returns a json representation of the histogram' do + histogram.update(4) + expect(JSON.parse(histogram.to_json)).to include( + 'count' => 1, + 'max' => 4.0, + 'min' => 4.0, + 'mean' => 4.0, + 'stddev' => 0.0, + 'p50' => 4.0, + 'p75' => 4.0, + 'p95' => 4.0, + 'p99' => 4.0, + 'p999' => 4.0, + ) + end end end end diff --git a/spec/multimeter/meter_spec.rb b/spec/multimeter/meter_spec.rb index ec031be..dd98524 100644 --- a/spec/multimeter/meter_spec.rb +++ b/spec/multimeter/meter_spec.rb @@ -42,16 +42,16 @@ module Multimeter end end - describe '#to_h' do - it 'returns a hash representation of the meter' do + describe '#to_json' do + it 'returns a json representation of the meter' do meter.mark - h = meter.to_h - expect(h[:type]).to eq(:meter) - expect(h[:count]).to eq(1) - expect(h[:mean_rate]).to be_a(Numeric) - expect(h[:one_minute_rate]).to be_a(Numeric) - expect(h[:five_minute_rate]).to be_a(Numeric) - expect(h[:fifteen_minute_rate]).to be_a(Numeric) + expect(JSON.parse(meter.to_json)).to include( + 'count' => 1, + 'mean_rate' => be_a(Numeric), + 'm1_rate' => be_a(Numeric), + 'm5_rate' => be_a(Numeric), + 'm15_rate' => be_a(Numeric), + ) end end end diff --git a/spec/multimeter/metric_registry_spec.rb b/spec/multimeter/metric_registry_spec.rb index cf711c9..d17dbb5 100644 --- a/spec/multimeter/metric_registry_spec.rb +++ b/spec/multimeter/metric_registry_spec.rb @@ -159,17 +159,21 @@ module Multimeter end end - describe '#to_h' do - it 'returns a hash representing all the metrics' do + describe '#to_json' do + it 'returns a json representing all the metrics' do metric_registry.counter('a_counter') metric_registry.meter('a_meter') metric_registry.timer('a_timer') metric_registry.histogram('an_histogram') - metric_registry.gauge('a_gauge') { 3 } - h = metric_registry.to_h - expect(h.keys).to contain_exactly(*%w[a_counter a_meter a_timer an_histogram a_gauge]) - h.each do |key, value| - expect(value).to eq(metric_registry.metrics[key].to_h) + metric_registry.gauge('a_gauge', :int) { 3 } + h = JSON.parse(metric_registry.to_json) + metric_types = %w[counters meters timers histograms gauges] + expect(h.keys).to match_array(metric_types + ['version']) + metric_types.each do |type| + metrics = h[type] + metrics.each do |name, value| + expect(value).to eq(JSON.parse(metric_registry.metrics[name].to_json)) + end end end end diff --git a/spec/multimeter/http_spec.rb b/spec/multimeter/rack_spec.rb similarity index 53% rename from spec/multimeter/http_spec.rb rename to spec/multimeter/rack_spec.rb index dcf5b41..8e08fa0 100644 --- a/spec/multimeter/http_spec.rb +++ b/spec/multimeter/rack_spec.rb @@ -1,72 +1,67 @@ # encoding: utf-8 require 'spec_helper' +require 'support/json_metrics' module Multimeter - describe 'Multimeter.http' do + describe 'Multimeter.create_app' do let :registry do Multimeter.create_registry end - context 'when handling requests' do - def extract_app - barrier = java.util.concurrent.Semaphore.new(0) - app, options = nil, nil - rack_handler = double(:rack_handler) - allow(rack_handler).to receive(:run) do |a, o| - app, options = a, o - barrier.release - end - Multimeter.http(registry, rack_handler) - expect(barrier.try_acquire(5, java.util.concurrent.TimeUnit::SECONDS)).not_to be_falsy - [app, options] - end - - before do - registry.counter('test').inc - end + let :app do + Multimeter.create_app(registry) + end + context 'when handling' do context 'valid requests' do - it 'responds with a JSON document created from calling #to_h on the registry' do - app, options = extract_app - status, headers, body = app.call({'QUERY_STRING' => ''}) - expect(body.join("\n")).to eq('{"test":{"type":"counter","count":1}}') + context 'responds with a JSON document that' do + let :json do + status, headers, body = app.call({'QUERY_STRING' => ''}) + body.join + end + + include_examples 'json-metrics' end it 'responds with application/json' do - app, options = extract_app status, headers, body = app.call({'QUERY_STRING' => ''}) expect(headers).to include('Content-Type' => 'application/json') end it 'responds with Connection: close' do - app, options = extract_app status, headers, body = app.call({'QUERY_STRING' => ''}) expect(headers).to include('Connection' => 'close') end it 'responds with CORS headers' do - app, _ = extract_app _, headers, _ = app.call({'QUERY_STRING' => ''}) expect(headers).to include('Access-Control-Allow-Origin' => '*') end - context 'JSONP' do - it 'responds with a JSON document wrapped in a function call when the callback parameter is given' do - app, options = extract_app - status, headers, body = app.call({'QUERY_STRING' => 'callback=the_cbk'}) - expect(body.join("\n")).to eq('the_cbk({"test":{"type":"counter","count":1}});') + context 'with a JSONP parameter' do + let :env do + { 'QUERY_STRING' => 'callback=the_cbk' } + end + + it 'wraps the body in a function call' do + status, headers, body = app.call(env) + expect(body.join).to match(/\Athe_cbk\(.*\);\Z/) + end + + it 'responds with a valid JSON document' do + status, headers, body = app.call(env) + json = body.join[/(?<=\Athe_cbk\().*(?=\);\Z)/] + expect { JSON.parse(json) }.not_to raise_error end it 'responds with application/javascript for JSONP request' do - app, options = extract_app - status, headers, body = app.call({'QUERY_STRING' => 'callback=the_cbk'}) + status, headers, body = app.call(env) expect(headers).to include('Content-Type' => 'application/javascript') end it 'responds with Connection: close' do - app, options = extract_app - status, headers, body = app.call({'QUERY_STRING' => 'callback=the_cbk'}) + status, headers, body = app.call(env) expect(headers).to include('Connection' => 'close') end end @@ -75,29 +70,26 @@ def extract_app context 'invalid requests' do context '400' do it 'responds with error 400 if the callback contains invalid chars' do - app, options = extract_app status, headers, body = app.call({'QUERY_STRING' => 'callback=apa*&^%$'}) expect(status).to eq(400) end it 'responds with Connection: close' do - app, options = extract_app status, headers, body = app.call({'QUERY_STRING' => 'callback=apa*&^%$'}) expect(headers).to include('Connection' => 'close') end end + context '500' do it 'responds with error 500 if an exception is thrown in the request handling' do - allow(registry).to receive(:to_h).and_raise('blurgh') - app, options = extract_app - status, headers, body = app.call({'QUERY_STRING' => 'callback=apa*&^%$'}) + allow(registry).to receive(:to_json).and_raise('blurgh') + status, headers, body = app.call({'QUERY_STRING' => ''}) expect(status).to eq(500) end it 'responds with Connection: close' do - allow(registry).to receive(:to_h).and_raise('blurgh') - app, options = extract_app - status, headers, body = app.call({'QUERY_STRING' => 'callback=apa*&^%$'}) + allow(registry).to receive(:to_json).and_raise('blurgh') + status, headers, body = app.call({'QUERY_STRING' => ''}) expect(headers).to include('Connection' => 'close') end end diff --git a/spec/multimeter/timer_spec.rb b/spec/multimeter/timer_spec.rb index 39ba2a7..b7c8d14 100644 --- a/spec/multimeter/timer_spec.rb +++ b/spec/multimeter/timer_spec.rb @@ -140,35 +140,25 @@ module Multimeter end end - describe '#to_h' do + describe '#to_json' do it 'returns a hash representation of the timer' do timer.update(100, :milliseconds) - h = timer.to_h - expect(h[:type]).to eq(:timer) - expect(h[:count]).to eq(1) - expect(h[:mean_rate]).to be_a(Numeric) - expect(h[:one_minute_rate]).to be_a(Numeric) - expect(h[:five_minute_rate]).to be_a(Numeric) - expect(h[:fifteen_minute_rate]).to be_a(Numeric) - expect(h[:max]).to be_a(Numeric) - expect(h[:min]).to be_a(Numeric) - expect(h[:mean]).to be_a(Numeric) - expect(h[:std_dev]).to be_a(Numeric) - expect(h[:median]).to be_a(Numeric) - expect(h[:percentiles]['75']).to be_a(Numeric) - expect(h[:percentiles]['95']).to be_a(Numeric) - expect(h[:percentiles]['98']).to be_a(Numeric) - expect(h[:percentiles]['99']).to be_a(Numeric) - expect(h[:percentiles]['99.9']).to be_a(Numeric) - end - - it 'scales the durations to milliseconds' do - timer.update(1, :seconds) - h = timer.to_h - expect(h[:max]).to eq(1000.0) - expect(h[:min]).to eq(1000.0) - expect(h[:mean]).to eq(1000.0) - expect(h[:median]).to eq(1000.0) + expect(JSON.parse(timer.to_json)).to include( + 'count' => 1, + 'mean_rate' => be_a(Numeric), + 'm1_rate' => be_a(Numeric), + 'm5_rate' => be_a(Numeric), + 'm15_rate' => be_a(Numeric), + 'max' => 100.0, + 'min' => 100.0, + 'mean' => 100.0, + 'stddev' => 0.0, + 'p50' => 100.0, + 'p75' => 100.0, + 'p95' => 100.0, + 'p99' => 100.0, + 'p999' => 100.0, + ) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 94de6a6..f68008e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,5 @@ require 'bundler/setup' require 'slf4j-jars' +require 'json' require 'multimeter' +require 'multimeter/http' diff --git a/spec/support/json_metrics.rb b/spec/support/json_metrics.rb new file mode 100644 index 0000000..9afbd33 --- /dev/null +++ b/spec/support/json_metrics.rb @@ -0,0 +1,120 @@ +require 'json' + +shared_examples 'json-metrics' do + let :metrics do + JSON.parse(json) + end + + it 'returns JSON' do + expect { metrics }.not_to raise_error + end + + shared_examples 'count' do + it 'includes count' do + expect(metric).to have_key('count') + end + end + + shared_examples 'rates' do + { + 'm1_rate' => 'one minute rate', + 'm5_rate' => 'five minute rate', + 'm15_rate' => 'fifteen minute rate', + 'mean_rate' => 'mean rate', + }.each do |key, name| + it "includes the #{name}" do + expect(metric).to have_key(key) + end + end + end + + shared_examples 'histogram' do + { + 'p50' => '50th percentile', + 'p75' => '75th percentile', + 'p95' => '95th percentile', + 'p98' => '98th percentile', + 'p99' => '99th percentile', + 'p999' => '99.9th percentile', + 'min' => 'minimum value', + 'max' => 'maximum value', + 'mean' => 'mean value', + 'stddev' => 'standard deviation', + }.each do |key, name| + it "includes the #{name}" do + expect(metric).to have_key(key) + end + end + end + + context 'with counters' do + before do + registry.counter('spec-counter').inc + end + + let :metric do + metrics['counters']['spec-counter'] + end + + include_examples 'count' + end + + context 'with meters' do + before do + registry.meter('spec-meter').mark + end + + let :metric do + metrics['meters']['spec-meter'] + end + + include_examples 'count' + include_examples 'rates' + end + + context 'with histograms' do + before do + registry.histogram('spec-histogram').update(1) + end + + let :metric do + metrics['histograms']['spec-histogram'] + end + + include_examples 'histogram' + end + + context 'with timers' do + before do + registry.timer('spec-timer').time {} + end + + let :metric do + metrics['timers']['spec-timer'] + end + + include_examples 'count' + include_examples 'rates' + include_examples 'histogram' + end + + context 'with gauges' do + before do + registry.gauge('spec-gauge', :string) { 'a string' } + end + + it 'inclues the value' do + expect(metrics['gauges']['spec-gauge']).to include('value' => 'a string') + end + + context "when type isn't specified" do + before do + registry.gauge('untyped-gauge') { -1 } + end + + it 'failes to serialize' do + expect { metrics }.to raise_error(JSON::ParserError) + end + end + end +end \ No newline at end of file