# Copyright (c) 2009-2013 RightScale Inc # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. module RightSupport::Stats # Track statistics for a given kind of activity class Activity # Number of samples included when calculating average recent activity # with the smoothing formula A = ((A * (RECENT_SIZE - 1)) + V) / RECENT_SIZE, # where A is the current recent average and V is the new activity value # As a rough guide, it takes approximately 2 * RECENT_SIZE activity values # at value V for average A to reach 90% of the original difference between A and V # For example, for A = 0, V = 1, RECENT_SIZE = 3 the progression for A is # 0, 0.3, 0.5, 0.7, 0.8, 0.86, 0.91, 0.94, 0.96, 0.97, 0.98, 0.99, ... RECENT_SIZE = 3 # Maximum string length for activity type MAX_TYPE_SIZE = 60 # (Integer) Total activity count attr_reader :total # (Hash) Count of activity per type attr_reader :count_per_type # Initialize activity data # # @param measure_rate [Boolean] Whether to measure activity rate def initialize(measure_rate = true) @measure_rate = measure_rate reset end # Reset statistics # # @return [TrueClass] Always return true def reset @interval = 0.0 @last_start_time = Time.now @avg_duration = nil @total = 0 @count_per_type = {} @last_type = nil @last_id = nil true end # Mark the start of an activity and update counts and average rate # with weighting toward past activity # Ignore the update if its type contains "stats" # # @param type [String, Symbol] Type of activity, with anything that is not a symbol, true, or false # automatically converted to a String and truncated to MAX_TYPE_SIZE characters, # defaults to nil # @param id [String] Unique identifier associated with this activity # # @return [Time] Update time def update(type = nil, id = nil) now = Time.now if type.nil? || !(type =~ /stats/) @interval = average(@interval, now - @last_start_time) if @measure_rate @last_start_time = now @total += 1 unless type.nil? unless [Symbol, TrueClass, FalseClass].include?(type.class) type = type.inspect unless type.is_a?(String) type = type[0, MAX_TYPE_SIZE - 3] + "..." if type.size > (MAX_TYPE_SIZE - 3) end @count_per_type[type] = (@count_per_type[type] || 0) + 1 end @last_type = type @last_id = id end now end # Mark the finish of an activity and update the average duration # # @param start_time [Time] Time when activity started, defaults to last time update was called # @param id [String] Unique identifier associated with this activity # # @return [Float] Activity duration in seconds def finish(start_time = nil, id = nil) now = Time.now start_time ||= @last_start_time duration = now - start_time @avg_duration = average(@avg_duration || 0.0, duration) @last_id = 0 if id && id == @last_id duration end # Convert average interval to average rate # # @return [Float, NilClass] Recent average rate, or nil if total is 0 def avg_rate if @total > 0 if @interval == 0.0 then 0.0 else 1.0 / @interval end end end # Get average duration of activity # # @return [Float, NilClass] Average duration in seconds of activity weighted # toward past activity, or nil if total is 0 def avg_duration @avg_duration if @total > 0 end # Get stats about last activity # # @return [Hash, NilClass] Information about last activity, or nil if the total is 0 # "elapsed" [Integer] Seconds since last activity started # "type" [String] Type of activity if specified, otherwise omitted # "active" [Boolean] Whether activity still active def last if @total > 0 result = {"elapsed" => (Time.now - @last_start_time).to_i} result["type"] = @last_type if @last_type result["active"] = @last_id != 0 if !@last_id.nil? result end end # Convert count per type into percentage by type # # @return [Hash, NilClass] Converted counts, or nil if total is 0 # "total" [Integer] Total activity count # "percent" [Hash] Percentage for each type of activity if tracking type, otherwise omitted def percentage if @total > 0 percent = {} @count_per_type.each { |k, v| percent[k] = (v / @total.to_f) * 100.0 } {"percent" => percent, "total" => @total} end end # Get stat summary including all aspects of activity that were measured except duration # # @return [Hash, NilClass] Information about activity, or nil if the total is 0 # "total" [Integer] Total activity count # "percent" [Hash] Percentage for each type of activity if tracking type, otherwise omitted # "last" [Hash] Information about last activity # "elapsed" [Integer] Seconds since last activity started # "type" [String] Type of activity if tracking type, otherwise omitted # "active" [Boolean] Whether activity still active if tracking whether active, otherwise omitted # "rate" [Float] Recent average rate if measuring rate, otherwise omitted def all if @total > 0 result = if @count_per_type.empty? {"total" => @total} else percentage end result.merge!("last" => last) result.merge!("rate" => avg_rate) if @measure_rate result end end protected # Calculate smoothed average with weighting toward past activity # # @param current [Float, Integer] Current average value # @param value [Float, Integer] New value # # @return [Float] New average def average(current, value) ((current * (RECENT_SIZE - 1)) + value) / RECENT_SIZE.to_f end public # Aggregate the stats from multiple 'all' calls # # @param stats [Array] Hashes that are to be aggregated # # @return [Hash, NilClass] Summed information about activity, or nil if the total is 0 # "total" [Integer] Total activity count # "percent" [Hash] Percentage for each type of activity if tracking type, otherwise omitted # "last" [Hash] Information about last activity # "elapsed" [Integer] Seconds since last activity started # "type" [String] Type of activity if tracking type, otherwise omitted # "active" [Boolean] Whether activity still active if tracking whether active, otherwise omitted # "rate" [Float] Recent average rate if measuring rate, otherwise omitted def self.all(stats) if (total = stats.inject(0) { |t, s| t += s["total"] if s && s["total"]; t }) > 0 all = percentage(stats, total) all["last"] = last(stats.map { |s| s["last"] if s }) rate = avg_rate(stats.map { |s| {"rate" => s["rate"], "total" => s["total"]} if s }, total) all["rate"] = rate if rate all end end # Aggregate multiple percentage stats # # @param stats [Array] List of stats containing "percent" hash and "total" value # @param total [Integer] Overall total # # @return [Hash] Converted counts # "total" [Integer] Total activity count # "percent" [Hash] Percentage for each type of activity if tracking type, omitted if no data def self.percentage(stats, total) count_per_type = {} stats.each do |s| if s t = s["total"] s["percent"].each do |k, v| count_per_type[k] = (count_per_type[k] || 0) + ((v / 100.0) * t).round end if s["percent"] end end if count_per_type.empty? {"total" => total} else percent = {} count_per_type.each { |k, v| percent[k] = (v / total.to_f) * 100.0 } {"percent" => percent, "total" => total} end end # Compute average rate from multiple average rates # # @param stats [Array] List of stats containing hash of "rate" and "total" # @param total [Integer] Overall total # # @return [Fixnum, NilClass] Average rate or nil if no average rate data def self.avg_rate(stats, total) sum = stats.inject(nil) { |sum, stat| sum = (sum || 0.0) + (stat["rate"] * stat["total"]) if stat["rate"]; sum } sum / total if sum end # Determine last activity from multiple last activity stats # # @param stats [Array] Multiple last activity stats # # @return [Hash, NilClass] Last activity, or nil if no last activity data def self.last(stats) stats.inject(nil) { |l, s| l = s if s && (l.nil? || (l["elapsed"] > s["elapsed"] && (s["type"] || s["active"]))); l } end end # Activity end # RightScale::Stats