require 'dash/typable' module Fiveruns::Dash class Metric include Typable attr_reader :name, :description, :help_text, :options attr_accessor :recipe def initialize(name, *args, &block) @@warned = false @name = name.to_s @options = args.last.is_a?(Hash) ? args.pop : {} @description = args.shift || Util.titleize(@name) @help_text = args.shift @operation = block @virtual = !!options[:sources] @abstract = options[:abstract] validate! end # Indicates that this metric is calculated based on the value(s) # of other metrics. def virtual? @virtual end # Indicates that this metric is only used for virtual calculations # and should not be sent to the server for storage. def abstract? @abstract end def data return nil if virtual? value_hash.merge(key) end def calculate(real_data) return nil unless virtual? datas = options[:sources].map {|met_name| real_data.detect { |hash| hash[:name] == met_name } }.compact if datas.size != options[:sources].size && options[:sources].include?('response_time') Fiveruns::Dash.logger.warn(<<-LOG ActiveRecord utilization metrics require a time metric so Dash can calculate a percentage of time spent in the database. Please set the :ar_total_time option when configuring Dash: # Define an application-specific metric cooresponding to the total processing time for this app. Fiveruns::Dash.register_recipe :loader, :url => 'http://dash.fiveruns.com' do |recipe| recipe.time :total_time, 'Load Time', :method => 'Loader::Engine#load' end # Pass the name of this custom metric to Dash so it will be used in the AR metric calculations. Fiveruns::Dash.configure :app => token, :ar_total_time => 'total_time' do |config| config.add_recipe :activerecord config.add_recipe :loader, :url => 'http://dash.fiveruns.com' end LOG ) unless @@warned @@warned = true return nil else raise ArgumentError, "Could not find one or more of #{options[:sources].inspect} in #{real_data.map { |h| h[:name] }.inspect}" unless datas.size == options[:sources].size end combine(datas.map { |hsh| hsh[:values] }).merge(key) end def reset # Abstract end def info key end def key @key ||= begin { :name => name, :recipe_url => recipe ? recipe.url : nil, :recipe_name => recipe ? recipe.name.to_s : nil, :data_type => self.class.metric_type, :description => description, :help_text => help_text, }.merge(optional_info) end end def ==(other) key == other.key end # Set context finder def find_context_with(&block) @context_finder = block end ####### private ####### def validate! raise ArgumentError, "'#{name}' should be between 3 and 32 characters" unless (3..32).include?(name.size) raise ArgumentError, "'#{name}' should only contain letters, numbers and underscore" if name !~ /\A\w+\Z/ raise ArgumentError, "#{name} - Virtual metrics should have source metrics" if virtual? && Util.blank?(options[:sources]) raise ArgumentError, "#{name} - metrics should not have source metrics" if !virtual? && options[:sources] end def optional_info optional = {} copy = optional.merge(@options[:unit] ? {:unit => @options[:unit].to_s} : {}) copy = copy.merge(@options[:scope] ? {:scope => @options[:scope].to_s} : {}) copy = copy.merge(abstract? ? {:abstract => true} : {}) optional.merge!(copy) optional end def combine(source_values) # Get the intersection of contexts for all the source metrics. # We combine the values for all shared contexts. contexts = source_values.map { |values| values.map { |value| value[:context] }} intersection = nil contexts.each_with_index do |arr, idx| if idx == 0 intersection = arr else intersection = intersection & arr end end values = intersection.map do |context| args = source_values.map do |values| values.detect { |value| value[:context] == context }[:value] end { :value => metric_callback(*args), :context => context } end {:values => values} end def value_hash current_value = ::Fiveruns::Dash.sync { metric_callback } {:values => parse_value(current_value)} end def metric_callback(*args) begin @operation.call(*args) rescue Exception => ex Fiveruns::Dash.logger.error("Metric #{recipe ? "#{recipe.name}/" : ''}#{@name} raised #{ex.class.name}: #{ex.message}") Fiveruns::Dash.logger.error(ex.backtrace.join("\n")) 0 end end # Verifies value matches one of the following patterns: # * A numeric value (indicates no namespace) # * A hash of [namespace_kind, namespace_name, ...] => value pairs, eg: # [:controller, 'FooController', :action, 'bar'] => 12 def parse_value(value) case value when Numeric [{:context => [], :value => value}] when Hash value.inject([]) do |all, (key, val)| case key when nil all.push :context => [], :value => val when Array if key.size % 2 == 0 all.push :context => key, :value => val else bad_value! "Contexts must have an even number of items" end else bad_value! "Unknown context type: #{key.class.name}" end all end else bad_value! "Unknown value type: #{value.class.name}" end end def bad_value!(message) raise ArgumentError, "Bad data for `#{@name}' #{self.class.metric_type} metric: #{message}" end # Note: only to be used when the +@operation+ # block is used to set contexts def find_containers(*args, &block) #:nodoc: contexts = Array(current_context_for(*args)) if contexts.empty? || contexts == [[]] contexts = [[]] elsif contexts.all? { |item| !item.is_a?(Array) } contexts = [contexts] end if Thread.current[:trace] result = yield blank_data[[]] Thread.current[:trace].add_data(self, contexts, result) end contexts.each do |context| with_container_for_context(context, &block) end end # Get the container for this context, allow modifications to it, # and store it # * Note: We sync here when looking up the container, while # the block is being executed, and when it is stored def with_container_for_context(context) ctx = (context || []).dup # normalize nil context to empty ::Fiveruns::Dash.sync do container = @data[ctx] new_container = yield container #Fiveruns::Dash.logger.info "#{name}/#{context.inspect}/#{new_container.inspect}" @data[ctx] = new_container # For hash defaults end end def context_finder @context_finder ||= begin context_setting = @options[:context] || @options[:contexts] context_setting.is_a?(Proc) ? context_setting : lambda { |*args| Array(context_setting) } end end # Retrieve the context for the given arguments # * Note: We need to sync here (and wherever the context is modified) def current_context_for(*args) ::Fiveruns::Dash.sync { context_finder.call(*args) } end end class TimeMetric < Metric def initialize(*args) super(*args) reset install_hook end def reset ::Fiveruns::Dash.sync do @data = blank_data end end ####### private ####### def blank_data Hash.new {{ :invocations => 0, :value => 0 }} end def value_hash values = {:values => current_value} reset values end def install_hook @operation ||= lambda { nil } methods_to_instrument.each do |meth| Instrument.add meth, instrument_options do |obj, time, *args| find_containers(obj, *args) do |container| container[:invocations] += 1 container[:value] += time container end end end end def instrument_options options = {} options[:reentrant_token] = self.object_id.abs if @options[:reentrant] options[:only_within] = @options[:only_within] if @options[:only_within] options[:mark_as] = @name if @options[:mark] options end def methods_to_instrument @methods_to_instrument ||= begin Array(@options[:method]) + Array(@options[:methods]) end end def validate! super raise ArgumentError, "Can not set :unit for `#{@name}' time metric" if @options[:unit] if Util.blank?(methods_to_instrument) raise ArgumentError, "Must set :method or :methods option for `#{@name}` time metric" end end # Get the current value # * Note: We sync here (and wherever @data is being written) def current_value ::Fiveruns::Dash.sync do @data.inject([]) do |all, (context, data)| all.push(data.merge(:context => context)) end end end end class CounterMetric < Metric def initialize(*args) super(*args) if incrementing_methods.any? reset install_hook end end def value_hash if incrementing_methods.any? values = {:values => current_value} reset values else super end end def install_hook if Util.blank?(incrementing_methods) raise RuntimeError, "Bad configuration for `#{@name}` counter metric" end @operation ||= lambda { nil } incrementing_methods.each do |meth| Instrument.add meth do |obj, time, *args| find_containers(obj, *args) do |container| container += 1 container end end end end # Reset the current value # * Note: We sync here (and wherever @data is being written) def reset ::Fiveruns::Dash.sync { @data = blank_data } end def blank_data Hash.new(0) end def incrementing_methods @incrementing_methods ||= Array(@options[:incremented_by]) end def validate! super if !@options[:incremented_by] raise ArgumentError, "No block given to capture counter `#{@name}'" unless @operation end end # Get the current value # * Note: We sync here (and wherever @data is being written) def current_value result = ::Fiveruns::Dash.sync do # Ensure the empty context is stored with a default of 0 @data[[]] = @data.fetch([], 0) @data end parse_value result end end class PercentageMetric < Metric def validate! super raise ArgumentError, "Can not set :unit for `#{@name}' percentage metric" if @options[:unit] end end class AbsoluteMetric < Metric end end