goyalmunish/request_response_stats

View on GitHub
lib/request_response_stats/redis_record.rb

Summary

Maintainability
A
2 hrs
Test Coverage
# File: lib/request_response_stats/redis_record.rb

require 'active_support/time'
require 'active_support/core_ext/hash/indifferent_access'
require 'json'

module RequestResponseStats
  class RedisRecord
    attr_accessor :key, :value

    REDIS_RR_KEY_NAMESPACE = "api_req_res"

    PERMITTED_KEYS = [
      :key_name,
      :server_name,
      :api_name,
      :api_verb,
      :api_controller,
      :api_action,
      :request_count,
      :min_time,
      :max_time,
      :avg_time,
      :start_time,
      :end_time,
      :error_count,
      :min_used_memory_MB,
      :max_used_memory_MB,
      :avg_used_memory_MB,
      :min_swap_memory_MB,
      :max_swap_memory_MB,
      :avg_swap_memory_MB,
      :avg_gc_stat_diff,
      :min_gc_stat_diff,
      :max_gc_stat_diff,
    ].map(&:to_s)

    AT_MAX_TIME = nil unless defined? AT_MAX_TIME
    AT_ERROR_COUNT = nil unless defined? AT_ERROR_COUNT
    AT_MAX_SWAP_MEMORY_MB = nil unless defined? AT_MAX_SWAP_MEMORY_MB
    ALERT_THRESHOLD = {
      max_time: AT_MAX_TIME || 30,
      error_count: AT_ERROR_COUNT || 2,
      max_swap_memory_MB: AT_MAX_SWAP_MEMORY_MB || 200,
    }.stringify_keys

    class << self

      # returns the redis connection
      # this method must be redefined for `RedisRecord` to be useable
      def redis
        raise StandardError, "UNDEFINED #{__method__}"
      end

      # get value from redis
      # wrapper from redis' `get` method
      def get(key)
        redis.get(key)
      end

      # set value to redis
      # wrapper from redis' `set` method
      def set(key, value, options={})
        redis.set(key, value, options)
      end

      # delete value from redis
      # wrapper from redis' `del` method
      def del(key)
        redis.del(key)
      end

      # returns all request_response_stats relevant redis keys
      # by default only PUBLIC keys are returned
      def all_keys(opts={})
        support = opts[:support] || false
        if support
          regex = /^#{REDIS_RR_KEY_NAMESPACE}/
        else
          regex = /^#{REDIS_RR_KEY_NAMESPACE}_PUBLIC/
        end

        redis.keys.select{|k| k =~ regex}.sort.reverse
      end

      # return parsed value from redis
      def parsed_get(key)
        JSON.parse(redis.get(key) || "{}")
      end

      # it returns parsed result into the format required for Mongo dump
      def formatted_parsed_get_for_mongo(key)
        data = parsed_get(key)
        data["start_time"] = date_time_str_to_obj(data["start_time"])
        data["end_time"] = date_time_str_to_obj(data["end_time"])

        data
      end

      # returns collection of all relevant PUBLIC request_response_stats data from redis
      def hashify_all_data(opts={})
        support = opts[:support] || false
        req_res_stat = ActiveSupport::HashWithIndifferentAccess.new
        all_keys(support: support).each do |key|
          req_res_stat[key] = ActiveSupport::HashWithIndifferentAccess.new(parsed_get key)
        end

        req_res_stat
      end

      # flushes all request_response_stats data from redis
      def flush_all_keys
        redis.del(*all_keys(support: true)) if all_keys.present?
      end

      # it has to be overridden
      def group_stats_by_time_duration
        raise StandardError, "UNDEFINED #{__method__}"
      end

      def support_key(server_name, key_name="default")
        [REDIS_RR_KEY_NAMESPACE, "SUPPORT", server_name.to_s, key_name].join("_")
      end

      def req_key(server_name, req_object_id)
        support_key(server_name, ["REQ_OBJ", req_object_id].join("_"))
      end

      def req_res_key(server_name, api_name, api_http_verb)
        ["#{REDIS_RR_KEY_NAMESPACE}_PUBLIC_#{server_name}_#{api_name}_#{api_http_verb}", get_time_slot_name].compact.join("_")
      end

      def get_slot_range_for_key(redis_key)
        date_slot_string = redis_key.split("_")[-1]

        get_slot_range_for_date_slot_string(date_slot_string)
      end

      # set jsonified value to redis and raise alerts
      def jsonified_set(key, value, options={}, custom_options={strict_key_check: true})
        value.select!{|k,v| PERMITTED_KEYS.include? k.to_s} if custom_options[:strict_key_check]

        # set jsonified value to redis
        redis.set(key, value.to_json, options)

        # get alerts collection
        alerts = ALERT_THRESHOLD.select{ |k, v| value[k] >= v if value[k] }.map{|k,v| {
          redis_key: key,
          alarm_key: k,
          alarm_value: v,
          actual_value: value[k]
        }}
        alerts_data = {data: alerts}.to_json
        raise_alert(alerts_data) if alerts.present?

        # return alerts
        alerts_data
      end

      # returns all PUBLIC request_response_stats related freezed keys from redis
      # freezed key: redis key which will no longer be updated
      # only freezed keys are eligible to be moved to mongo
      def freezed_keys
        all_keys.map{|k| self.new(k)}.select{|k| k.is_key_freezed?}.map{|rr| rr.key}
      end

      # lets you fetch records for given conditions from redis
      def query(params={})
        # to implement
      end

      private

      # Example: for a value of `1.hour` for `group_stats_by_time_duration`, the data for each endpoint is
      # divided into `1 day / 1.hour = 24` time slots within a day
      def get_time_slot_name(current_time = Time.now)
        current_time_utc = current_time.utc
        seconds_since_beginning_of_today = (current_time_utc - Time.utc(current_time_utc.year, current_time_utc.month, current_time_utc.day)).to_i
        # seconds_in_a_day = 24 * 60 * 60
        num_of_slots_in_a_day = 24.hours / group_stats_by_time_duration
        seconds_in_a_time_slot = group_stats_by_time_duration.seconds.to_i
        current_slot = (seconds_since_beginning_of_today / seconds_in_a_time_slot).to_i
        slot_str_length = num_of_slots_in_a_day.to_s.size
        current_slot_name = "%0#{slot_str_length}d" % current_slot
        current_slot_full_name = [
          current_time_utc.year,
          "%02d" % current_time_utc.month,  # current_time_utc.month
          "%02d" % current_time_utc.day,  # current_time_utc.day
          current_slot_name
        ].join("-")

        current_slot_full_name
      end

      # returns time range of given date_slot_stringe
      # which is [start_time_of_slot, end_time_of_slot]
      def get_slot_range_for_date_slot_string(date_slot_string)
        year_num, month_num, date_num, slot_num = date_slot_string.split("-").map(&:to_i)
        time = Time.utc(year_num, month_num, date_num)

        # num_of_slots_in_a_day = 24.hours / group_stats_by_time_duration
        seconds_in_a_time_slot = group_stats_by_time_duration.seconds.to_i
        seconds_already_passed = slot_num * seconds_in_a_time_slot
        starting_time = time + seconds_already_passed
        ending_time = starting_time + seconds_in_a_time_slot

        [starting_time, ending_time]
      end

      # Input example: "2017-11-06 09:01:00 UTC"
      def date_time_str_to_obj(date_time_str)
        date, time, zone = date_time_str.split(" ")
        date = date.split("-").map(&:to_i)
        time = time.split(":").map(&:to_i)
        zone = "+00:00" if zone == ["UTC"]
        DateTime.new(*date, *time, zone)
      end

      def raise_alert(data)
        name.split("::")[0].constantize.custom_alert_code(data)
      end

    end

    def initialize(key, value=nil)
      @key = key
      @value = value
    end

    # if a key is freezed then no more data will be written to it
    def is_key_freezed?
      return nil unless self.class.group_stats_by_time_duration

      self.class.get_slot_range_for_key(key)[1] < Time.now
    end

  end
end