diff --git a/lib/librato/metrics.rb b/lib/librato/metrics.rb index dd72ba4..fc37e39 100644 --- a/lib/librato/metrics.rb +++ b/lib/librato/metrics.rb @@ -13,6 +13,7 @@ require 'metrics/persistence' require 'metrics/queue' require 'metrics/smart_json' +require 'metrics/util' require 'metrics/version' module Librato @@ -65,23 +66,22 @@ module Metrics extend SingleForwardable TYPES = [:counter, :gauge] - PLURAL_TYPES = [:counters, :gauges] + PLURAL_TYPES = TYPES.map { |type| "#{type}s".to_sym } MIN_MEASURE_TIME = (Time.now-(3600*24*365)).to_i # Most of the singleton methods of Librato::Metrics are actually # being called on a global Client instance. See further docs on # Client. # - def_delegators :client, :agent_identifier, :annotate, :api_endpoint, - :api_endpoint=, :authenticate, :connection, - :proxy, :proxy=, - :faraday_adapter, :faraday_adapter=, - :persistence, :persistence=, :persister, - :get_composite, :get_metric, :get_measurements, :metrics, - :delete_metrics, :update_metric, :update_metrics, - :submit, - :sources, :get_source, :update_source, - :create_snapshot, :get_snapshot + def_delegators :client, :agent_identifier, :annotate, + :api_endpoint, :api_endpoint=, :authenticate, + :connection, :create_snapshot, :delete_metrics, + :faraday_adapter, :faraday_adapter=, :get_composite, + :get_measurements, :get_metric, :get_series, + :get_snapshot, :get_source, :metrics, + :persistence, :persistence=, :persister, :proxy, :proxy=, + :sources, :submit, :update_metric, :update_metrics, + :update_source # The Librato::Metrics::Client being used by module-level # access. diff --git a/lib/librato/metrics/aggregator.rb b/lib/librato/metrics/aggregator.rb index 843eb89..66fee12 100644 --- a/lib/librato/metrics/aggregator.rb +++ b/lib/librato/metrics/aggregator.rb @@ -23,7 +23,7 @@ module Metrics # queue.merge!(aggregator) # class Aggregator - SOURCE_SEPARATOR = '%%' # must not be in valid source name criteria + SEPARATOR = '%%' # must not be in valid tags and/or source criteria include Processor @@ -52,20 +52,29 @@ def initialize(opts={}) # @return [Aggregator] returns self def add(measurements) measurements.each do |metric, data| + entry = {} if @prefix metric = "#{@prefix}.#{metric}" end + entry[:name] = metric.to_s if data.respond_to?(:each) # hash form + validate_parameters(data) value = data[:value] if data[:source] - metric = "#{metric}#{SOURCE_SEPARATOR}#{data[:source]}" + metric = "#{metric}#{SEPARATOR}#{data[:source]}" + entry[:source] = data[:source].to_s + elsif data[:tags] && data[:tags].respond_to?(:each) + metric = Librato::Metrics::Util.build_key_for(metric.to_s, data[:tags]) + entry[:tags] = data[:tags] end else value = data end - @aggregated[metric] ||= Aggregate.new - @aggregated[metric] << value + @aggregated[metric] = {} unless @aggregated[metric] + @aggregated[metric][:aggregate] ||= Aggregate.new + @aggregated[metric][:aggregate] << value + @aggregated[metric].merge!(entry) end autosubmit_check self @@ -88,31 +97,38 @@ def clear # Returns currently queued data # def queued - gauges = [] + entries = [] + multidimensional = has_tags? - @aggregated.each do |metric, data| - source = nil - metric = metric.to_s - if metric.include?(SOURCE_SEPARATOR) - metric, source = metric.split(SOURCE_SEPARATOR) - end + @aggregated.each_value do |data| entry = { - name: metric, - count: data.count, - sum: data.sum, - + name: data[:name], + count: data[:aggregate].count, + sum: data[:aggregate].sum, # TODO: make float/non-float consistent in the gem - min: data.min.to_f, - max: data.max.to_f + min: data[:aggregate].min.to_f, + max: data[:aggregate].max.to_f # TODO: expose v.sum2 and include } - entry[:source] = source if source - gauges << entry + if data[:source] + entry[:source] = data[:source] + elsif data[:tags] + multidimensional = true + entry[:tags] = data[:tags] + end + multidimensional = true if data[:time] + entries << entry end - - req = { gauges: gauges } + time = multidimensional ? :time : :measure_time + req = + if multidimensional + { measurements: entries } + else + { gauges: entries } + end req[:source] = @source if @source - req[:measure_time] = @measure_time if @measure_time + req[:tags] = @tags if has_tags? + req[time] = @time if @time req end diff --git a/lib/librato/metrics/client.rb b/lib/librato/metrics/client.rb index 0e35586..0dd9511 100644 --- a/lib/librato/metrics/client.rb +++ b/lib/librato/metrics/client.rb @@ -178,6 +178,40 @@ def get_metric(name, options = {}) parsed end + # Retrieve series of measurements for a given metric + # + # @example Get series for metric + # series = Librato::Metrics.get_series :requests, resolution: 1, duration: 3600 + # + # @example Get series for metric grouped by tag + # query = { duration: 3600, resolution: 1, group_by: "environment", group_by_function: "sum" } + # series = Librato::Metrics.get_series :requests, query + # + # @example Get series for metric grouped by tag and negated by tag filter + # query = { duration: 3600, resolution: 1, group_by: "environment", group_by_function: "sum", tags_search: "environment=!staging" } + # series = Librato::Metrics.get_series :requests, query + # + # @param [Symbol|String] metric_name Metric name + # @param [Hash] options Query options + def get_series(metric_name, options={}) + raise ArgumentError, ":resolution and :duration or :start_time must be set" if options.empty? + query = options.dup + if query[:start_time].respond_to?(:year) + query[:start_time] = query[:start_time].to_i + end + if query[:end_time].respond_to?(:year) + query[:end_time] = query[:end_time].to_i + end + query[:resolution] ||= 1 + unless query[:start_time] || query[:end_time] + query[:duration] ||= 3600 + end + url = connection.build_url("measurements/#{metric_name}", query) + response = connection.get(url) + parsed = SmartJSON.read(response.body) + parsed["series"] + end + # Retrieve data points for a specific metric # # @example Get 20 most recent data points for metric diff --git a/lib/librato/metrics/errors.rb b/lib/librato/metrics/errors.rb index aee95a6..55d52a3 100644 --- a/lib/librato/metrics/errors.rb +++ b/lib/librato/metrics/errors.rb @@ -9,6 +9,7 @@ class NoMetricsProvided < MetricsError; end class NoClientProvided < MetricsError; end class InvalidMeasureTime < MetricsError; end class NotMergeable < MetricsError; end + class InvalidParameters < MetricsError; end class NetworkError < StandardError attr_reader :response diff --git a/lib/librato/metrics/persistence/direct.rb b/lib/librato/metrics/persistence/direct.rb index 79a1622..cfb4fef 100644 --- a/lib/librato/metrics/persistence/direct.rb +++ b/lib/librato/metrics/persistence/direct.rb @@ -5,8 +5,6 @@ module Librato module Metrics module Persistence class Direct - MEASUREMENT_TYPES = [:gauges, :counters] - # Persist the queued metrics directly to the # Metrics web API. # @@ -18,9 +16,15 @@ def persist(client, queued, options={}) requests = [queued] end requests.each do |request| + resource = + if queued[:gauges] || queued[:counters] + "metrics" + else + "measurements" + end payload = SmartJSON.write(request) # expects 200 - client.connection.post('metrics', payload) + client.connection.post(resource, payload) end end @@ -31,16 +35,16 @@ def chunk_queued(queued, per_request) reqs = [] # separate metric-containing values from global values globals = fetch_globals(queued) - MEASUREMENT_TYPES.each do |metric_type| - metrics = queued[metric_type] + top_level_keys.each do |key| + metrics = queued[key] next unless metrics if metrics.size <= per_request # we can fit all of this metric type in a single request - reqs << build_request(metric_type, metrics, globals) + reqs << build_request(key, metrics, globals) else # going to have to split things up metrics.each_slice(per_request) do |elements| - reqs << build_request(metric_type, elements, globals) + reqs << build_request(key, elements, globals) end end end @@ -51,8 +55,12 @@ def build_request(type, metrics, globals) {type => metrics}.merge(globals) end + def top_level_keys + [Librato::Metrics::PLURAL_TYPES, :measurements].flatten + end + def fetch_globals(queued) - queued.reject {|k, v| MEASUREMENT_TYPES.include?(k)} + queued.reject { |k, v| top_level_keys.include?(k) } end def queue_count(queued) @@ -62,4 +70,4 @@ def queue_count(queued) end end end -end \ No newline at end of file +end diff --git a/lib/librato/metrics/processor.rb b/lib/librato/metrics/processor.rb index 64616d1..5524b7a 100644 --- a/lib/librato/metrics/processor.rb +++ b/lib/librato/metrics/processor.rb @@ -1,3 +1,5 @@ +require "set" + module Librato module Metrics @@ -7,7 +9,11 @@ module Processor MEASUREMENTS_PER_REQUEST = 500 attr_reader :per_request, :last_submit_time - attr_accessor :prefix + attr_accessor :prefix, :tags + + def tags + @tags ||= {} + end # The current Client instance this queue is using to authenticate # and connect to Librato Metrics. This will default to the primary @@ -19,6 +25,11 @@ def client @client ||= Librato::Metrics.client end + def has_tags? + !@tags.empty? + end + alias :tags? :has_tags? + # The object this MetricSet will use to persist # def persister @@ -82,11 +93,13 @@ def epoch_time end def setup_common_options(options) + validate_parameters(options) @autosubmit_interval = options[:autosubmit_interval] @client = options[:client] || Librato::Metrics.client @per_request = options[:per_request] || MEASUREMENTS_PER_REQUEST @source = options[:source] - @measure_time = options[:measure_time] && options[:measure_time].to_i + @tags = options.fetch(:tags, {}) + @time = (options[:time] && options[:time].to_i || options[:measure_time] && options[:measure_time].to_i) @create_time = Time.now @clear_on_failure = options[:clear_failures] || false @prefix = options[:prefix] @@ -99,6 +112,18 @@ def autosubmit_check end end + def validate_parameters(options) + invalid_combinations = [ + [:source, :tags], + ] + opts = options.keys.to_set + invalid_combinations.each do |combo| + if combo.to_set.subset?(opts) + raise InvalidParameters, "#{combo} cannot be simultaneously set" + end + end + end + end end diff --git a/lib/librato/metrics/queue.rb b/lib/librato/metrics/queue.rb index ad46319..78c9d54 100644 --- a/lib/librato/metrics/queue.rb +++ b/lib/librato/metrics/queue.rb @@ -28,7 +28,9 @@ def initialize(opts={}) # @return [Queue] returns self def add(measurements) measurements.each do |key, value| + multidimensional = has_tags? if value.respond_to?(:each) + validate_parameters(value) metric = value metric[:name] = key.to_s type = metric.delete(:type) || metric.delete('type') || 'gauge' @@ -39,15 +41,24 @@ def add(measurements) if @prefix metric[:name] = "#{@prefix}.#{metric[:name]}" end + multidimensional = true if metric[:tags] || metric[:time] type = ("#{type}s").to_sym - if metric[:measure_time] - metric[:measure_time] = metric[:measure_time].to_i + time_key = multidimensional ? :time : :measure_time + metric[:time] = metric.delete(:measure_time) if multidimensional && metric[:measure_time] + + if metric[time_key] + metric[time_key] = metric[time_key].to_i check_measure_time(metric) elsif !skip_measurement_times - metric[:measure_time] = epoch_time + metric[time_key] = epoch_time + end + if multidimensional + @queued[:measurements] ||= [] + @queued[:measurements] << metric + else + @queued[type] ||= [] + @queued[type] << metric end - @queued[type] ||= [] - @queued[type] << metric end submit_check self @@ -81,6 +92,10 @@ def gauges @queued[:gauges] || [] end + def measurements + @queued[:measurements] || [] + end + # Combines queueable measures from the given object # into this queue. # @@ -99,14 +114,24 @@ def merge!(mergeable) end Metrics::PLURAL_TYPES.each do |type| if to_merge[type] - measurements = reconcile_source(to_merge[type], to_merge[:source]) + payload = reconcile(to_merge[type], to_merge[:source]) if @queued[type] - @queued[type] += measurements + @queued[type] += payload else - @queued[type] = measurements + @queued[type] = payload end end end + + if to_merge[:measurements] + payload = reconcile(to_merge[:measurements], to_merge[:tags]) + if @queued[:measurements] + @queued[:measurements] += payload + else + @queued[:measurements] = payload + end + end + submit_check self end @@ -117,8 +142,10 @@ def merge!(mergeable) def queued return {} if @queued.empty? globals = {} + time = has_tags? ? :time : :measure_time + globals[time] = @time if @time globals[:source] = @source if @source - globals[:measure_time] = @measure_time if @measure_time + globals[:tags] = @tags if has_tags? @queued.merge(globals) end @@ -133,16 +160,19 @@ def size private def check_measure_time(data) - if data[:measure_time] < Metrics::MIN_MEASURE_TIME + time_keys = [:measure_time, :time] + + if time_keys.any? { |key| data[key] && data[key] < Metrics::MIN_MEASURE_TIME } raise InvalidMeasureTime, "Measure time for submitted metric (#{data}) is invalid." end end - def reconcile_source(measurements, source) - return measurements if !source || source == @source + def reconcile(measurements, val) + arr = val.is_a?(Hash) ? [@tags, :tags] : [@source, :source] + return measurements if !val || val == arr.first measurements.map! do |measurement| - unless measurement[:source] - measurement[:source] = source + unless measurement[arr.last] + measurement[arr.last] = val end measurement end diff --git a/lib/librato/metrics/util.rb b/lib/librato/metrics/util.rb new file mode 100644 index 0000000..8488758 --- /dev/null +++ b/lib/librato/metrics/util.rb @@ -0,0 +1,25 @@ +module Librato + module Metrics + + class Util + SEPARATOR = "%%" + + # Builds a Hash key from metric name and tags. + # + # @param metric_name [String] The unique identifying metric name of the property being tracked. + # @param tags [Hash] A set of name=value tag pairs that describe the particular data stream. + # @return [String] the Hash key + def self.build_key_for(metric_name, tags) + key_name = metric_name + tags.sort.each do |key, value| + k = key.to_s.downcase + v = value.is_a?(String) ? value.downcase : value + key_name = "#{key_name}#{SEPARATOR}#{k}=#{v}" + end + key_name + end + + end + + end +end diff --git a/spec/integration/metrics/queue_spec.rb b/spec/integration/metrics/queue_spec.rb index 1ae0a0c..86c3617 100644 --- a/spec/integration/metrics/queue_spec.rb +++ b/spec/integration/metrics/queue_spec.rb @@ -5,7 +5,9 @@ module Metrics describe Queue do before(:all) { prep_integration_tests } - before(:each) { delete_all_metrics } + before(:each) do + delete_all_metrics + end context "with a large number of metrics" do it "submits them in multiple requests" do @@ -70,6 +72,24 @@ module Metrics expect(bar['barsource'][0]['value']).to eq(456) end + context "with tags" do + let(:queue) { Queue.new(tags: { hostname: "metrics-web-stg-1" }) } + + it "respects default and individual tags" do + queue.add test_1: 123 + queue.add test_2: { value: 456, tags: { hostname: "metrics-web-stg-2" }} + queue.submit + + test_1 = Librato::Metrics.get_series :test_1, resolution: 1, duration: 3600 + expect(test_1[0]["tags"]["hostname"]).to eq("metrics-web-stg-1") + expect(test_1[0]["measurements"][0]["value"]).to eq(123) + + test_2 = Librato::Metrics.get_series :test_2, resolution: 1, duration: 3600 + expect(test_2[0]["tags"]["hostname"]).to eq("metrics-web-stg-2") + expect(test_2[0]["measurements"][0]["value"]).to eq(456) + end + end + end end diff --git a/spec/integration/metrics_spec.rb b/spec/integration/metrics_spec.rb index 590b565..4029be4 100644 --- a/spec/integration/metrics_spec.rb +++ b/spec/integration/metrics_spec.rb @@ -360,6 +360,17 @@ module Librato end + describe "#get_series" do + before { Metrics.submit test_series: { value: 123, tags: { hostname: "metrics-web-stg-1" } } } + + it "gets series" do + series = Metrics.get_series :test_series, resolution: 1, duration: 3600 + + expect(series[0]["tags"]["hostname"]).to eq("metrics-web-stg-1") + expect(series[0]["measurements"][0]["value"]).to eq(123) + end + end + # Note: These are challenging to test end-to-end, should probably # unit test instead. Disabling for now. # diff --git a/spec/unit/metrics/aggregator_spec.rb b/spec/unit/metrics/aggregator_spec.rb index 8b82d6d..381de9f 100644 --- a/spec/unit/metrics/aggregator_spec.rb +++ b/spec/unit/metrics/aggregator_spec.rb @@ -38,6 +38,69 @@ module Metrics expect(a.source).to be_nil end end + + context "with valid arguments" do + it "initializes Aggregator" do + expect { Aggregator.new }.not_to raise_error + expect { Aggregator.new(source: "metrics-web-stg-1") }.not_to raise_error + expect { Aggregator.new(tags: { hostname: "metrics-web-stg-1" }) }.not_to raise_error + end + end + + context "with invalid arguments" do + it "raises exception" do + expect { + Aggregator.new( + source: "metrics-web-stg-1", + tags: { hostname: "metrics-web-stg-1" } + ) + }.to raise_error(InvalidParameters) + end + end + end + + describe "#tags" do + context "when set" do + let(:aggregator) { Aggregator.new(tags: { instance_id: "i-1234567a" }) } + it "gets @tags" do + expect(aggregator.tags).to be_a(Hash) + expect(aggregator.tags.keys).to include(:instance_id) + expect(aggregator.tags[:instance_id]).to eq("i-1234567a") + end + end + + context "when not set" do + let(:aggregator) { Aggregator.new } + it "defaults to empty hash" do + expect(aggregator.tags).to be_a(Hash) + expect(aggregator.tags).to be_empty + end + end + end + + describe "#tags=" do + it "sets @tags" do + expected_tags = { instance_id: "i-1234567b" } + expect{subject.tags = expected_tags}.to change{subject.tags}.from({}).to(expected_tags) + expect(subject.tags).to be_a(Hash) + expect(subject.tags).to eq(expected_tags) + end + end + + describe "#has_tags?" do + context "when tags are set" do + it "returns true" do + subject.tags = { instance_id: "i-1234567f" } + + expect(subject.has_tags?).to eq(true) + end + end + + context "when tags are not set" do + it "returns false" do + expect(subject.has_tags?).to eq(false) + end + end end describe "#add" do @@ -45,6 +108,14 @@ module Metrics expect(subject.add(foo: 1234)).to eq(subject) end + context "with invalid arguments" do + it "raises exception" do + expect { + subject.add test: { source: "metrics-web-stg-1", tags: { hostname: "metrics-web-stg-1" }, value: 123 } + }.to raise_error(InvalidParameters) + end + end + context "with single hash argument" do it "records a single aggregate" do subject.add foo: 3000 @@ -91,6 +162,21 @@ module Metrics expect(subject.queued).to equal_unordered(expected) end + context "when per-measurement tags" do + it "maintains specified tags" do + subject.add test: { tags: { hostname: "metrics-web-stg-1" }, value: 1 } + subject.add test: 5 + subject.add test: { tags: { hostname: "metrics-web-stg-1" }, value: 6 } + subject.add test: 10 + expected = [ + { name: "test", tags: { hostname: "metrics-web-stg-1" }, count: 2, sum: 7.0, min: 1.0, max: 6.0 }, + { name: "test", count: 2, sum: 15.0, min: 5.0, max: 10.0 } + ] + + expect(subject.queued[:measurements]).to equal_unordered(expected) + end + end + context "with a prefix set" do it "auto-prepends names" do subject = Aggregator.new(prefix: 'foo') @@ -160,6 +246,63 @@ module Metrics expect(subject.queued).to equal_unordered(expected) end end + + context "with tags" do + context "when Aggregator is initialized with tags" do + let(:aggregator) { Aggregator.new(tags: { region: "us-east-1" }) } + + it "applies top-level tags" do + expected = { name: "test", count: 2, sum: 3, min: 1, max: 2 } + aggregator.add test: 1 + aggregator.add test: 2 + + expect(aggregator.queued[:tags]).to eq({ region: "us-east-1" }) + expect(aggregator.queued[:measurements].first).to eq(expected) + end + end + + context "when tags are used as arguments" do + let(:aggregator) { Aggregator.new } + + it "applies per-measurement tags" do + expected = { name: "test", count: 2, sum: 3, min: 1, max: 2, tags: { hostname: "metrics-web-stg-1" } } + aggregator.add test: { value: 1, tags: { hostname: "metrics-web-stg-1" } } + aggregator.add test: { value: 2, tags: { hostname: "metrics-web-stg-1" } } + + expect(aggregator.queued[:tags]).to be_nil + expect(aggregator.queued[:measurements].first).to eq(expected) + end + + context "when tags arguments are not sorted" do + let(:aggregator) { Aggregator.new } + + it "uses sorted tags hash key" do + expected = { name: "test", count: 2, sum: 3, min: 1, max: 2, tags: { a: 1, b: 2, c: 3 } } + aggregator.add test: { value: 1, tags: { c: 3, b: 2, a: 1 } } + aggregator.add test: { value: 2, tags: { b: 2, a: 1, c: 3 } } + + expect(aggregator.queued[:tags]).to be_nil + expect(aggregator.queued[:measurements].first).to eq(expected) + end + end + end + + context "when Aggregator is initialized with tags and when tags are used as arguments" do + let(:aggregator) { Aggregator.new(tags: { region: "us-east-1" }) } + + it "applies top-level tags and per-measurement tags" do + expected = { name: "test", count: 3, sum: 12, min: 3, max: 5, tags: { hostname: "metrics-web-stg-1" } } + aggregator.add test: { value: 3, tags: { hostname: "metrics-web-stg-1" } } + aggregator.add test: { value: 4, tags: { hostname: "metrics-web-stg-1" } } + aggregator.add test: { value: 5, tags: { hostname: "metrics-web-stg-1" } } + aggregator.add test: { value: 1, tags: { hostname: "metrics-web-stg-2" } } + aggregator.add test: { value: 2, tags: { region: "us-tirefire-1" } } + + expect(aggregator.queued[:tags]).to eq({ region: "us-east-1" }) + expect(aggregator.queued[:measurements].first).to eq(expected) + end + end + end end describe "#queued" do @@ -171,10 +314,30 @@ module Metrics it "includes global measure_time if set" do measure_time = (Time.now-1000).to_i - a = Aggregator.new(measure_time: measure_time) + a = Aggregator.new(source: "foo", measure_time: measure_time) a.add foo: 12 expect(a.queued[:measure_time]).to eq(measure_time) end + + context "when tags are set" do + it "includes global tags" do + expected_tags = { region: "us-east-1" } + subject = Aggregator.new(tags: expected_tags) + subject.add test: 5 + + expect(subject.queued[:tags]).to eq(expected_tags) + end + end + + context "when time is set" do + it "includes global time" do + expected_time = (Time.now-1000).to_i + subject = Aggregator.new(tags: { foo: "bar" }, time: expected_time) + subject.add test: 10 + + expect(subject.queued[:time]).to eq(expected_time) + end + end end describe "#submit" do diff --git a/spec/unit/metrics/queue_spec.rb b/spec/unit/metrics/queue_spec.rb index 6a50c52..163f34f 100644 --- a/spec/unit/metrics/queue_spec.rb +++ b/spec/unit/metrics/queue_spec.rb @@ -5,16 +5,22 @@ module Metrics describe Queue do - before(:each) do + before(:all) do @time = (Time.now.to_i - 1*60) allow_any_instance_of(Queue).to receive(:epoch_time).and_return(@time) end describe "initialization" do context "with specified client" do + let(:barney) { Client } + let(:queue) { Queue.new(client: barney) } + before do + allow(barney).to receive(:has_tags?).and_return(false) + allow(barney).to receive(:tags).and_return({}) + allow(barney).to receive(:add_tags).and_return({}) + end + it "sets to client" do - barney = Client - queue = Queue.new(client: barney) expect(queue.client).to eq(barney) end end @@ -25,6 +31,69 @@ module Metrics expect(queue.client).to eq(Librato::Metrics.client) end end + + context "with valid arguments" do + it "initializes Queue" do + expect { Queue.new }.not_to raise_error + expect { Queue.new(source: "metrics-web-stg-1") }.not_to raise_error + expect { Queue.new(tags: { hostname: "metrics-web-stg-1" }) }.not_to raise_error + end + end + + context "with invalid arguments" do + it "raises exception" do + expect { + Queue.new( + source: "metrics-web-stg-1", + tags: { hostname: "metrics-web-stg-1" } + ) + }.to raise_error(InvalidParameters) + end + end + end + + describe "#tags" do + context "when set" do + let(:queue) { Queue.new(tags: { instance_id: "i-1234567a" }) } + it "gets @tags" do + expect(queue.tags).to be_a(Hash) + expect(queue.tags.keys).to include(:instance_id) + expect(queue.tags[:instance_id]).to eq("i-1234567a") + end + end + + context "when not set" do + let(:queue) { Queue.new } + it "defaults to empty hash" do + expect(queue.tags).to be_a(Hash) + expect(queue.tags).to be_empty + end + end + end + + describe "#tags=" do + it "sets @tags" do + expected_tags = { instance_id: "i-1234567b" } + expect{subject.tags = expected_tags}.to change{subject.tags}.from({}).to(expected_tags) + expect(subject.tags).to be_a(Hash) + expect(subject.tags).to eq(expected_tags) + end + end + + describe "#has_tags?" do + context "when tags are set" do + it "returns true" do + subject.tags = { instance_id: "i-1234567f" } + + expect(subject.has_tags?).to eq(true) + end + end + + context "when tags are not set" do + it "returns false" do + expect(subject.has_tags?).to eq(false) + end + end end describe "#add" do @@ -32,6 +101,14 @@ module Metrics expect(subject.add(foo: 123)).to eq(subject) end + context "with invalid arguments" do + it "raises exception" do + expect { + subject.add test: { source: "metrics-web-stg-1", tags: { hostname: "metrics-web-stg-1" }, value: 123 } + }.to raise_error(InvalidParameters) + end + end + context "with single hash argument" do it "records a key-value gauge" do expected = {gauges: [{name: 'foo', value: 3000, measure_time: @time}]} @@ -137,6 +214,70 @@ module Metrics }.to raise_error(InvalidMeasureTime) end end + + context "with tags" do + context "when Queue is initialized with tags" do + let(:queue) { Queue.new(tags: { region: "us-east-1" }) } + + it "applies top-level tags" do + expected = { name: "test", value: 1, time: @time } + queue.add test: 1 + + expect(queue.queued[:tags]).to eq({ region: "us-east-1" }) + expect(queue.queued[:measurements].first).to eq(expected) + end + end + + context "when tags are used as arguments" do + let(:queue) { Queue.new } + + it "applies per-measurement tags" do + expected = { name: "test", value: 2, tags: { hostname: "metrics-web-stg-1" }, time: @time } + queue.add test: { value: 2, tags: { hostname: "metrics-web-stg-1" } } + + expect(queue.queued[:tags]).to be_nil + expect(queue.queued[:measurements].first).to eq(expected) + end + + it "converts legacy measure_time to time" do + expected_time = Time.now.to_i + expected_tags = { foo: "bar" } + expected = { + measurements: [{ + name: "test", value: 1, tags: expected_tags, time: expected_time + }] + } + + subject.add test: { value: 1, tags: expected_tags, measure_time: expected_time } + + expect(subject.queued).to equal_unordered(expected) + end + end + + context "when Queue is initialized with tags and when tags are used as arguments" do + let(:queue) { Queue.new(tags: { region: "us-east-1" }) } + + it "applies top-level tags and per-measurement tags" do + expected = { name: "test", value: 3, tags: { hostname: "metrics-web-stg-1" }, time: @time } + queue.add test: { value: 3, tags: { hostname: "metrics-web-stg-1" } } + + expect(queue.queued[:tags]).to eq({ region: "us-east-1" }) + expect(queue.queued[:measurements].first).to eq(expected) + end + end + end + end + + describe "#measurements" do + it "returns currently queued measurements" do + subject.add test_1: { tags: { region: "us-east-1" }, value: 1 }, + test_2: { type: :counter, value: 2 } + expect(subject.measurements).to eq([{ name: "test_1", value: 1, tags: { region: "us-east-1" }, time: @time }]) + end + + it "returns [] when no queued measurements" do + expect(subject.measurements).to be_empty + end end describe "#counters" do @@ -219,6 +360,39 @@ module Metrics expect(q2.queued).to equal_unordered(expected) end + context "with tags" do + it "maintains specified tags" do + q1 = Queue.new + q1.add test: { tags: { hostname: "metrics-web-stg-1" }, value: 123 } + q2 = Queue.new(tags: { hostname: "metrics-web-stg-2" }) + q2.merge!(q1) + + expect(q2.queued[:measurements].first[:tags][:hostname]).to eq("metrics-web-stg-1") + end + + it "does not change top-level tags" do + q1 = Queue.new(tags: { hostname: "metrics-web-stg-1" }) + q1.add test: 456 + q2 = Queue.new(tags: { hostname: "metrics-web-stg-2" }) + q2.merge!(q1) + + expect(q2.queued[:tags][:hostname]).to eq("metrics-web-stg-2") + end + + it "tracks previous default tags" do + q1 = Queue.new(tags: { instance_id: "i-1234567a" }) + q1.add test_1: 123 + q2 = Queue.new(tags: { instance_type: "m3.medium" }) + q2.add test_2: 456 + q2.merge!(q1) + metric = q2.measurements.find { |measurement| measurement[:name] == "test_1" } + + expect(metric[:tags][:instance_id]).to eq("i-1234567a") + expect(q2.queued[:tags]).to eq({ instance_type: "m3.medium" }) + + end + end + it "maintains specified sources" do q1 = Queue.new q1.add neo: {source: 'matrix', value: 123} @@ -247,6 +421,7 @@ module Metrics end end end + end it "handles empty cases" do q1 = Queue.new @@ -257,7 +432,6 @@ module Metrics gauges: [{name:"foo", value:123, measure_time: @time}]} expect(q2.queued).to eq(expected) end - end context "with an aggregator" do it "merges" do @@ -301,10 +475,29 @@ module Metrics it "includes global measure_time if set" do measure_time = (Time.now-1000).to_i - q = Queue.new(measure_time: measure_time) + q = Queue.new(source: "foo", measure_time: measure_time) q.add foo: 12 expect(q.queued[:measure_time]).to eq(measure_time) end + + context "when tags are set" do + it "includes global tags" do + expected_tags = { region: "us-east-1" } + queue = Queue.new(tags: expected_tags) + queue.add test: 5 + expect(queue.queued[:tags]).to eq(expected_tags) + end + end + + context "when time is set" do + it "includes global time" do + expected_time = (Time.now-1000).to_i + queue = Queue.new(tags: { foo: "bar" }, time: expected_time) + queue.add test: 10 + expect(queue.queued[:time]).to eq(expected_time) + end + end + end describe "#size" do @@ -318,6 +511,15 @@ module Metrics register_cents: {type: :gauge, value: 211101} expect(subject.size).to eq(4) end + + context "when measurement present" do + it "returns count of measurements" do + subject.add test_1: { tags: { hostname: "metrics-web-stg-1" }, value: 1 }, + test_2: { tags: { hostname: "metrics-web-stg-2" }, value: 2} + + expect(subject.size).to eq(2) + end + end end describe "#submit" do diff --git a/spec/unit/metrics/util_spec.rb b/spec/unit/metrics/util_spec.rb new file mode 100644 index 0000000..c19bb23 --- /dev/null +++ b/spec/unit/metrics/util_spec.rb @@ -0,0 +1,23 @@ +require "spec_helper" + +module Librato + module Metrics + + describe Util do + + describe "#build_key_for" do + it "builds a Hash key" do + metric_name = "requests" + tags = { status: 200, MeThoD: "GET", controller: "users", ACTION: "show" } + expected = "requests%%action=show%%method=get%%controller=users%%status=200" + actual = Util.build_key_for(metric_name, tags) + + expect(expected).to eq(actual) + end + + end + + end + + end +end