analytics-ruby 2.0.13 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +5 -5
- data/bin/analytics +108 -0
- data/lib/analytics-ruby.rb +1 -0
- data/lib/segment/analytics/backoff_policy.rb +49 -0
- data/lib/segment/analytics/client.rb +111 -269
- data/lib/segment/analytics/defaults.rb +20 -4
- data/lib/segment/analytics/field_parser.rb +192 -0
- data/lib/segment/analytics/logging.rb +36 -11
- data/lib/segment/analytics/message_batch.rb +72 -0
- data/lib/segment/analytics/response.rb +0 -1
- data/lib/segment/analytics/test_queue.rb +56 -0
- data/lib/segment/analytics/transport.rb +138 -0
- data/lib/segment/analytics/utils.rb +18 -19
- data/lib/segment/analytics/version.rb +1 -1
- data/lib/segment/analytics/worker.rb +20 -11
- data/lib/segment/analytics.rb +15 -6
- metadata +65 -34
- data/Gemfile +0 -2
- data/Gemfile.lock +0 -53
- data/History.md +0 -160
- data/Makefile +0 -8
- data/README.md +0 -91
- data/Rakefile +0 -7
- data/analytics-ruby.gemspec +0 -23
- data/lib/segment/analytics/request.rb +0 -82
- data/spec/segment/analytics/client_spec.rb +0 -291
- data/spec/segment/analytics/worker_spec.rb +0 -96
- data/spec/segment/analytics_spec.rb +0 -95
- data/spec/spec_helper.rb +0 -81
| @@ -0,0 +1,192 @@ | |
| 1 | 
            +
            module Segment
         | 
| 2 | 
            +
              class Analytics
         | 
| 3 | 
            +
                # Handles parsing fields according to the Segment Spec
         | 
| 4 | 
            +
                #
         | 
| 5 | 
            +
                # @see https://segment.com/docs/spec/
         | 
| 6 | 
            +
                class FieldParser
         | 
| 7 | 
            +
                  class << self
         | 
| 8 | 
            +
                    include Segment::Analytics::Utils
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                    # In addition to the common fields, track accepts:
         | 
| 11 | 
            +
                    #
         | 
| 12 | 
            +
                    # - "event"
         | 
| 13 | 
            +
                    # - "properties"
         | 
| 14 | 
            +
                    def parse_for_track(fields)
         | 
| 15 | 
            +
                      common = parse_common_fields(fields)
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                      event = fields[:event]
         | 
| 18 | 
            +
                      properties = fields[:properties] || {}
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                      check_presence!(event, 'event')
         | 
| 21 | 
            +
                      check_is_hash!(properties, 'properties')
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                      isoify_dates! properties
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                      common.merge({
         | 
| 26 | 
            +
                        :type => 'track',
         | 
| 27 | 
            +
                        :event => event.to_s,
         | 
| 28 | 
            +
                        :properties => properties
         | 
| 29 | 
            +
                      })
         | 
| 30 | 
            +
                    end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                    # In addition to the common fields, identify accepts:
         | 
| 33 | 
            +
                    #
         | 
| 34 | 
            +
                    # - "traits"
         | 
| 35 | 
            +
                    def parse_for_identify(fields)
         | 
| 36 | 
            +
                      common = parse_common_fields(fields)
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                      traits = fields[:traits] || {}
         | 
| 39 | 
            +
                      check_is_hash!(traits, 'traits')
         | 
| 40 | 
            +
                      isoify_dates! traits
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                      common.merge({
         | 
| 43 | 
            +
                        :type => 'identify',
         | 
| 44 | 
            +
                        :traits => traits
         | 
| 45 | 
            +
                      })
         | 
| 46 | 
            +
                    end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                    # In addition to the common fields, alias accepts:
         | 
| 49 | 
            +
                    #
         | 
| 50 | 
            +
                    # - "previous_id"
         | 
| 51 | 
            +
                    def parse_for_alias(fields)
         | 
| 52 | 
            +
                      common = parse_common_fields(fields)
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                      previous_id = fields[:previous_id]
         | 
| 55 | 
            +
                      check_presence!(previous_id, 'previous_id')
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                      common.merge({
         | 
| 58 | 
            +
                        :type => 'alias',
         | 
| 59 | 
            +
                        :previousId => previous_id
         | 
| 60 | 
            +
                      })
         | 
| 61 | 
            +
                    end
         | 
| 62 | 
            +
             | 
| 63 | 
            +
                    # In addition to the common fields, group accepts:
         | 
| 64 | 
            +
                    #
         | 
| 65 | 
            +
                    # - "group_id"
         | 
| 66 | 
            +
                    # - "traits"
         | 
| 67 | 
            +
                    def parse_for_group(fields)
         | 
| 68 | 
            +
                      common = parse_common_fields(fields)
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                      group_id = fields[:group_id]
         | 
| 71 | 
            +
                      traits = fields[:traits] || {}
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                      check_presence!(group_id, 'group_id')
         | 
| 74 | 
            +
                      check_is_hash!(traits, 'traits')
         | 
| 75 | 
            +
             | 
| 76 | 
            +
                      isoify_dates! traits
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                      common.merge({
         | 
| 79 | 
            +
                        :type => 'group',
         | 
| 80 | 
            +
                        :groupId => group_id,
         | 
| 81 | 
            +
                        :traits => traits
         | 
| 82 | 
            +
                      })
         | 
| 83 | 
            +
                    end
         | 
| 84 | 
            +
             | 
| 85 | 
            +
                    # In addition to the common fields, page accepts:
         | 
| 86 | 
            +
                    #
         | 
| 87 | 
            +
                    # - "name"
         | 
| 88 | 
            +
                    # - "properties"
         | 
| 89 | 
            +
                    def parse_for_page(fields)
         | 
| 90 | 
            +
                      common = parse_common_fields(fields)
         | 
| 91 | 
            +
             | 
| 92 | 
            +
                      name = fields[:name] || ''
         | 
| 93 | 
            +
                      properties = fields[:properties] || {}
         | 
| 94 | 
            +
             | 
| 95 | 
            +
                      check_is_hash!(properties, 'properties')
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                      isoify_dates! properties
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                      common.merge({
         | 
| 100 | 
            +
                        :type => 'page',
         | 
| 101 | 
            +
                        :name => name.to_s,
         | 
| 102 | 
            +
                        :properties => properties
         | 
| 103 | 
            +
                      })
         | 
| 104 | 
            +
                    end
         | 
| 105 | 
            +
             | 
| 106 | 
            +
                    # In addition to the common fields, screen accepts:
         | 
| 107 | 
            +
                    #
         | 
| 108 | 
            +
                    # - "name"
         | 
| 109 | 
            +
                    # - "properties"
         | 
| 110 | 
            +
                    # - "category" (Not in spec, retained for backward compatibility"
         | 
| 111 | 
            +
                    def parse_for_screen(fields)
         | 
| 112 | 
            +
                      common = parse_common_fields(fields)
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                      name = fields[:name]
         | 
| 115 | 
            +
                      properties = fields[:properties] || {}
         | 
| 116 | 
            +
                      category = fields[:category]
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                      check_presence!(name, 'name')
         | 
| 119 | 
            +
                      check_is_hash!(properties, 'properties')
         | 
| 120 | 
            +
             | 
| 121 | 
            +
                      isoify_dates! properties
         | 
| 122 | 
            +
             | 
| 123 | 
            +
                      parsed = common.merge({
         | 
| 124 | 
            +
                        :type => 'screen',
         | 
| 125 | 
            +
                        :name => name,
         | 
| 126 | 
            +
                        :properties => properties
         | 
| 127 | 
            +
                      })
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                      parsed[:category] = category if category
         | 
| 130 | 
            +
             | 
| 131 | 
            +
                      parsed
         | 
| 132 | 
            +
                    end
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                    private
         | 
| 135 | 
            +
             | 
| 136 | 
            +
                    def parse_common_fields(fields)
         | 
| 137 | 
            +
                      timestamp = fields[:timestamp] || Time.new
         | 
| 138 | 
            +
                      message_id = fields[:message_id].to_s if fields[:message_id]
         | 
| 139 | 
            +
                      context = fields[:context] || {}
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                      check_user_id! fields
         | 
| 142 | 
            +
                      check_timestamp! timestamp
         | 
| 143 | 
            +
             | 
| 144 | 
            +
                      add_context! context
         | 
| 145 | 
            +
             | 
| 146 | 
            +
                      parsed = {
         | 
| 147 | 
            +
                        :context => context,
         | 
| 148 | 
            +
                        :messageId => message_id,
         | 
| 149 | 
            +
                        :timestamp => datetime_in_iso8601(timestamp)
         | 
| 150 | 
            +
                      }
         | 
| 151 | 
            +
             | 
| 152 | 
            +
                      parsed[:userId] = fields[:user_id] if fields[:user_id]
         | 
| 153 | 
            +
                      parsed[:anonymousId] = fields[:anonymous_id] if fields[:anonymous_id]
         | 
| 154 | 
            +
                      parsed[:integrations] = fields[:integrations] if fields[:integrations]
         | 
| 155 | 
            +
             | 
| 156 | 
            +
                      # Not in spec, retained for backward compatibility
         | 
| 157 | 
            +
                      parsed[:options] = fields[:options] if fields[:options]
         | 
| 158 | 
            +
             | 
| 159 | 
            +
                      parsed
         | 
| 160 | 
            +
                    end
         | 
| 161 | 
            +
             | 
| 162 | 
            +
                    def check_user_id!(fields)
         | 
| 163 | 
            +
                      unless fields[:user_id] || fields[:anonymous_id]
         | 
| 164 | 
            +
                        raise ArgumentError, 'Must supply either user_id or anonymous_id'
         | 
| 165 | 
            +
                      end
         | 
| 166 | 
            +
                    end
         | 
| 167 | 
            +
             | 
| 168 | 
            +
                    def check_timestamp!(timestamp)
         | 
| 169 | 
            +
                      raise ArgumentError, 'Timestamp must be a Time' unless timestamp.is_a? Time
         | 
| 170 | 
            +
                    end
         | 
| 171 | 
            +
             | 
| 172 | 
            +
                    def add_context!(context)
         | 
| 173 | 
            +
                      context[:library] = { :name => 'analytics-ruby', :version => Segment::Analytics::VERSION.to_s }
         | 
| 174 | 
            +
                    end
         | 
| 175 | 
            +
             | 
| 176 | 
            +
                    # private: Ensures that a string is non-empty
         | 
| 177 | 
            +
                    #
         | 
| 178 | 
            +
                    # obj    - String|Number that must be non-blank
         | 
| 179 | 
            +
                    # name   - Name of the validated value
         | 
| 180 | 
            +
                    def check_presence!(obj, name)
         | 
| 181 | 
            +
                      if obj.nil? || (obj.is_a?(String) && obj.empty?)
         | 
| 182 | 
            +
                        raise ArgumentError, "#{name} must be given"
         | 
| 183 | 
            +
                      end
         | 
| 184 | 
            +
                    end
         | 
| 185 | 
            +
             | 
| 186 | 
            +
                    def check_is_hash!(obj, name)
         | 
| 187 | 
            +
                      raise ArgumentError, "#{name} must be a Hash" unless obj.is_a? Hash
         | 
| 188 | 
            +
                    end
         | 
| 189 | 
            +
                  end
         | 
| 190 | 
            +
                end
         | 
| 191 | 
            +
              end
         | 
| 192 | 
            +
            end
         | 
| @@ -2,24 +2,49 @@ require 'logger' | |
| 2 2 |  | 
| 3 3 | 
             
            module Segment
         | 
| 4 4 | 
             
              class Analytics
         | 
| 5 | 
            +
                # Wraps an existing logger and adds a prefix to all messages
         | 
| 6 | 
            +
                class PrefixedLogger
         | 
| 7 | 
            +
                  def initialize(logger, prefix)
         | 
| 8 | 
            +
                    @logger = logger
         | 
| 9 | 
            +
                    @prefix = prefix
         | 
| 10 | 
            +
                  end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  def debug(msg)
         | 
| 13 | 
            +
                    @logger.debug("#{@prefix} #{msg}")
         | 
| 14 | 
            +
                  end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                  def info(msg)
         | 
| 17 | 
            +
                    @logger.info("#{@prefix} #{msg}")
         | 
| 18 | 
            +
                  end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                  def warn(msg)
         | 
| 21 | 
            +
                    @logger.warn("#{@prefix} #{msg}")
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                  def error(msg)
         | 
| 25 | 
            +
                    @logger.error("#{@prefix} #{msg}")
         | 
| 26 | 
            +
                  end
         | 
| 27 | 
            +
                end
         | 
| 28 | 
            +
             | 
| 5 29 | 
             
                module Logging
         | 
| 6 30 | 
             
                  class << self
         | 
| 7 31 | 
             
                    def logger
         | 
| 8 | 
            -
                      @logger  | 
| 9 | 
            -
                                    Rails.logger
         | 
| 10 | 
            -
                                  else
         | 
| 11 | 
            -
                                    logger = Logger.new STDOUT
         | 
| 12 | 
            -
                                    logger.progname = 'Segment::Analytics'
         | 
| 13 | 
            -
                                    logger
         | 
| 14 | 
            -
                                  end
         | 
| 15 | 
            -
                    end
         | 
| 32 | 
            +
                      return @logger if @logger
         | 
| 16 33 |  | 
| 17 | 
            -
             | 
| 18 | 
            -
             | 
| 34 | 
            +
                      base_logger = if defined?(Rails)
         | 
| 35 | 
            +
                                      Rails.logger
         | 
| 36 | 
            +
                                    else
         | 
| 37 | 
            +
                                      logger = Logger.new STDOUT
         | 
| 38 | 
            +
                                      logger.progname = 'Segment::Analytics'
         | 
| 39 | 
            +
                                      logger
         | 
| 40 | 
            +
                                    end
         | 
| 41 | 
            +
                      @logger = PrefixedLogger.new(base_logger, '[analytics-ruby]')
         | 
| 19 42 | 
             
                    end
         | 
| 43 | 
            +
             | 
| 44 | 
            +
                    attr_writer :logger
         | 
| 20 45 | 
             
                  end
         | 
| 21 46 |  | 
| 22 | 
            -
                  def self.included | 
| 47 | 
            +
                  def self.included(base)
         | 
| 23 48 | 
             
                    class << base
         | 
| 24 49 | 
             
                      def logger
         | 
| 25 50 | 
             
                        Logging.logger
         | 
| @@ -0,0 +1,72 @@ | |
| 1 | 
            +
            require 'forwardable'
         | 
| 2 | 
            +
            require 'segment/analytics/logging'
         | 
| 3 | 
            +
             | 
| 4 | 
            +
            module Segment
         | 
| 5 | 
            +
              class Analytics
         | 
| 6 | 
            +
                # A batch of `Message`s to be sent to the API
         | 
| 7 | 
            +
                class MessageBatch
         | 
| 8 | 
            +
                  class JSONGenerationError < StandardError; end
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  extend Forwardable
         | 
| 11 | 
            +
                  include Segment::Analytics::Logging
         | 
| 12 | 
            +
                  include Segment::Analytics::Defaults::MessageBatch
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  def initialize(max_message_count)
         | 
| 15 | 
            +
                    @messages = []
         | 
| 16 | 
            +
                    @max_message_count = max_message_count
         | 
| 17 | 
            +
                    @json_size = 0
         | 
| 18 | 
            +
                  end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                  def <<(message)
         | 
| 21 | 
            +
                    begin
         | 
| 22 | 
            +
                      message_json = message.to_json
         | 
| 23 | 
            +
                    rescue StandardError => e
         | 
| 24 | 
            +
                      raise JSONGenerationError, "Serialization error: #{e}"
         | 
| 25 | 
            +
                    end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                    message_json_size = message_json.bytesize
         | 
| 28 | 
            +
                    if message_too_big?(message_json_size)
         | 
| 29 | 
            +
                      logger.error('a message exceeded the maximum allowed size')
         | 
| 30 | 
            +
                    else
         | 
| 31 | 
            +
                      @messages << message
         | 
| 32 | 
            +
                      @json_size += message_json_size + 1 # One byte for the comma
         | 
| 33 | 
            +
                    end
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                  def full?
         | 
| 37 | 
            +
                    item_count_exhausted? || size_exhausted?
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
             | 
| 40 | 
            +
                  def clear
         | 
| 41 | 
            +
                    @messages.clear
         | 
| 42 | 
            +
                    @json_size = 0
         | 
| 43 | 
            +
                  end
         | 
| 44 | 
            +
             | 
| 45 | 
            +
                  def_delegators :@messages, :to_json
         | 
| 46 | 
            +
                  def_delegators :@messages, :empty?
         | 
| 47 | 
            +
                  def_delegators :@messages, :length
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                  private
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  def item_count_exhausted?
         | 
| 52 | 
            +
                    @messages.length >= @max_message_count
         | 
| 53 | 
            +
                  end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                  def message_too_big?(message_json_size)
         | 
| 56 | 
            +
                    message_json_size > Defaults::Message::MAX_BYTES
         | 
| 57 | 
            +
                  end
         | 
| 58 | 
            +
             | 
| 59 | 
            +
                  # We consider the max size here as just enough to leave room for one more
         | 
| 60 | 
            +
                  # message of the largest size possible. This is a shortcut that allows us
         | 
| 61 | 
            +
                  # to use a native Ruby `Queue` that doesn't allow peeking. The tradeoff
         | 
| 62 | 
            +
                  # here is that we might fit in less messages than possible into a batch.
         | 
| 63 | 
            +
                  #
         | 
| 64 | 
            +
                  # The alternative is to use our own `Queue` implementation that allows
         | 
| 65 | 
            +
                  # peeking, and to consider the next message size when calculating whether
         | 
| 66 | 
            +
                  # the message can be accomodated in this batch.
         | 
| 67 | 
            +
                  def size_exhausted?
         | 
| 68 | 
            +
                    @json_size >= (MAX_BYTES - Defaults::Message::MAX_BYTES)
         | 
| 69 | 
            +
                  end
         | 
| 70 | 
            +
                end
         | 
| 71 | 
            +
              end
         | 
| 72 | 
            +
            end
         | 
| @@ -0,0 +1,56 @@ | |
| 1 | 
            +
            module Segment
         | 
| 2 | 
            +
              class Analytics
         | 
| 3 | 
            +
                class TestQueue
         | 
| 4 | 
            +
                  attr_reader :messages
         | 
| 5 | 
            +
             | 
| 6 | 
            +
                  def initialize
         | 
| 7 | 
            +
                    reset!
         | 
| 8 | 
            +
                  end
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                  def [](key)
         | 
| 11 | 
            +
                    all[key]
         | 
| 12 | 
            +
                  end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  def count
         | 
| 15 | 
            +
                    all.count
         | 
| 16 | 
            +
                  end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                  def <<(message)
         | 
| 19 | 
            +
                    all << message
         | 
| 20 | 
            +
                    send(message[:type]) << message
         | 
| 21 | 
            +
                  end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                  def alias
         | 
| 24 | 
            +
                    messages[:alias] ||= []
         | 
| 25 | 
            +
                  end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                  def all
         | 
| 28 | 
            +
                    messages[:all] ||= []
         | 
| 29 | 
            +
                  end
         | 
| 30 | 
            +
             | 
| 31 | 
            +
                  def group
         | 
| 32 | 
            +
                    messages[:group] ||= []
         | 
| 33 | 
            +
                  end
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                  def identify
         | 
| 36 | 
            +
                    messages[:identify] ||= []
         | 
| 37 | 
            +
                  end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                  def page
         | 
| 40 | 
            +
                    messages[:page] ||= []
         | 
| 41 | 
            +
                  end
         | 
| 42 | 
            +
             | 
| 43 | 
            +
                  def screen
         | 
| 44 | 
            +
                    messages[:screen] ||= []
         | 
| 45 | 
            +
                  end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                  def track
         | 
| 48 | 
            +
                    messages[:track] ||= []
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  def reset!
         | 
| 52 | 
            +
                    @messages = {}
         | 
| 53 | 
            +
                  end
         | 
| 54 | 
            +
                end
         | 
| 55 | 
            +
              end
         | 
| 56 | 
            +
            end
         | 
| @@ -0,0 +1,138 @@ | |
| 1 | 
            +
            require 'segment/analytics/defaults'
         | 
| 2 | 
            +
            require 'segment/analytics/utils'
         | 
| 3 | 
            +
            require 'segment/analytics/response'
         | 
| 4 | 
            +
            require 'segment/analytics/logging'
         | 
| 5 | 
            +
            require 'segment/analytics/backoff_policy'
         | 
| 6 | 
            +
            require 'net/http'
         | 
| 7 | 
            +
            require 'net/https'
         | 
| 8 | 
            +
            require 'json'
         | 
| 9 | 
            +
             | 
| 10 | 
            +
            module Segment
         | 
| 11 | 
            +
              class Analytics
         | 
| 12 | 
            +
                class Transport
         | 
| 13 | 
            +
                  include Segment::Analytics::Defaults::Request
         | 
| 14 | 
            +
                  include Segment::Analytics::Utils
         | 
| 15 | 
            +
                  include Segment::Analytics::Logging
         | 
| 16 | 
            +
             | 
| 17 | 
            +
                  def initialize(options = {})
         | 
| 18 | 
            +
                    options[:host] ||= HOST
         | 
| 19 | 
            +
                    options[:port] ||= PORT
         | 
| 20 | 
            +
                    options[:ssl] ||= SSL
         | 
| 21 | 
            +
                    @headers = options[:headers] || HEADERS
         | 
| 22 | 
            +
                    @path = options[:path] || PATH
         | 
| 23 | 
            +
                    @retries = options[:retries] || RETRIES
         | 
| 24 | 
            +
                    @backoff_policy =
         | 
| 25 | 
            +
                      options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                    http = Net::HTTP.new(options[:host], options[:port])
         | 
| 28 | 
            +
                    http.use_ssl = options[:ssl]
         | 
| 29 | 
            +
                    http.read_timeout = 8
         | 
| 30 | 
            +
                    http.open_timeout = 4
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                    @http = http
         | 
| 33 | 
            +
                  end
         | 
| 34 | 
            +
             | 
| 35 | 
            +
                  # Sends a batch of messages to the API
         | 
| 36 | 
            +
                  #
         | 
| 37 | 
            +
                  # @return [Response] API response
         | 
| 38 | 
            +
                  def send(write_key, batch)
         | 
| 39 | 
            +
                    logger.debug("Sending request for #{batch.length} items")
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                    last_response, exception = retry_with_backoff(@retries) do
         | 
| 42 | 
            +
                      status_code, body = send_request(write_key, batch)
         | 
| 43 | 
            +
                      error = JSON.parse(body)['error']
         | 
| 44 | 
            +
                      should_retry = should_retry_request?(status_code, body)
         | 
| 45 | 
            +
                      logger.debug("Response status code: #{status_code}")
         | 
| 46 | 
            +
                      logger.debug("Response error: #{error}") if error
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                      [Response.new(status_code, error), should_retry]
         | 
| 49 | 
            +
                    end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                    if exception
         | 
| 52 | 
            +
                      logger.error(exception.message)
         | 
| 53 | 
            +
                      exception.backtrace.each { |line| logger.error(line) }
         | 
| 54 | 
            +
                      Response.new(-1, exception.to_s)
         | 
| 55 | 
            +
                    else
         | 
| 56 | 
            +
                      last_response
         | 
| 57 | 
            +
                    end
         | 
| 58 | 
            +
                  end
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                  # Closes a persistent connection if it exists
         | 
| 61 | 
            +
                  def shutdown
         | 
| 62 | 
            +
                    @http.finish if @http.started?
         | 
| 63 | 
            +
                  end
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                  private
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                  def should_retry_request?(status_code, body)
         | 
| 68 | 
            +
                    if status_code >= 500
         | 
| 69 | 
            +
                      true # Server error
         | 
| 70 | 
            +
                    elsif status_code == 429
         | 
| 71 | 
            +
                      true # Rate limited
         | 
| 72 | 
            +
                    elsif status_code >= 400
         | 
| 73 | 
            +
                      logger.error(body)
         | 
| 74 | 
            +
                      false # Client error. Do not retry, but log
         | 
| 75 | 
            +
                    else
         | 
| 76 | 
            +
                      false
         | 
| 77 | 
            +
                    end
         | 
| 78 | 
            +
                  end
         | 
| 79 | 
            +
             | 
| 80 | 
            +
                  # Takes a block that returns [result, should_retry].
         | 
| 81 | 
            +
                  #
         | 
| 82 | 
            +
                  # Retries upto `retries_remaining` times, if `should_retry` is false or
         | 
| 83 | 
            +
                  # an exception is raised. `@backoff_policy` is used to determine the
         | 
| 84 | 
            +
                  # duration to sleep between attempts
         | 
| 85 | 
            +
                  #
         | 
| 86 | 
            +
                  # Returns [last_result, raised_exception]
         | 
| 87 | 
            +
                  def retry_with_backoff(retries_remaining, &block)
         | 
| 88 | 
            +
                    result, caught_exception = nil
         | 
| 89 | 
            +
                    should_retry = false
         | 
| 90 | 
            +
             | 
| 91 | 
            +
                    begin
         | 
| 92 | 
            +
                      result, should_retry = yield
         | 
| 93 | 
            +
                      return [result, nil] unless should_retry
         | 
| 94 | 
            +
                    rescue StandardError => e
         | 
| 95 | 
            +
                      should_retry = true
         | 
| 96 | 
            +
                      caught_exception = e
         | 
| 97 | 
            +
                    end
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                    if should_retry && (retries_remaining > 1)
         | 
| 100 | 
            +
                      logger.debug("Retrying request, #{retries_remaining} retries left")
         | 
| 101 | 
            +
                      sleep(@backoff_policy.next_interval.to_f / 1000)
         | 
| 102 | 
            +
                      retry_with_backoff(retries_remaining - 1, &block)
         | 
| 103 | 
            +
                    else
         | 
| 104 | 
            +
                      [result, caught_exception]
         | 
| 105 | 
            +
                    end
         | 
| 106 | 
            +
                  end
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                  # Sends a request for the batch, returns [status_code, body]
         | 
| 109 | 
            +
                  def send_request(write_key, batch)
         | 
| 110 | 
            +
                    payload = JSON.generate(
         | 
| 111 | 
            +
                      :sentAt => datetime_in_iso8601(Time.now),
         | 
| 112 | 
            +
                      :batch => batch
         | 
| 113 | 
            +
                    )
         | 
| 114 | 
            +
                    request = Net::HTTP::Post.new(@path, @headers)
         | 
| 115 | 
            +
                    request.basic_auth(write_key, nil)
         | 
| 116 | 
            +
             | 
| 117 | 
            +
                    if self.class.stub
         | 
| 118 | 
            +
                      logger.debug "stubbed request to #{@path}: " \
         | 
| 119 | 
            +
                        "write key = #{write_key}, batch = #{JSON.generate(batch)}"
         | 
| 120 | 
            +
             | 
| 121 | 
            +
                      [200, '{}']
         | 
| 122 | 
            +
                    else
         | 
| 123 | 
            +
                      @http.start unless @http.started? # Maintain a persistent connection
         | 
| 124 | 
            +
                      response = @http.request(request, payload)
         | 
| 125 | 
            +
                      [response.code.to_i, response.body]
         | 
| 126 | 
            +
                    end
         | 
| 127 | 
            +
                  end
         | 
| 128 | 
            +
             | 
| 129 | 
            +
                  class << self
         | 
| 130 | 
            +
                    attr_writer :stub
         | 
| 131 | 
            +
             | 
| 132 | 
            +
                    def stub
         | 
| 133 | 
            +
                      @stub || ENV['STUB']
         | 
| 134 | 
            +
                    end
         | 
| 135 | 
            +
                  end
         | 
| 136 | 
            +
                end
         | 
| 137 | 
            +
              end
         | 
| 138 | 
            +
            end
         | 
| @@ -8,7 +8,9 @@ module Segment | |
| 8 8 | 
             
                  # public: Return a new hash with keys converted from strings to symbols
         | 
| 9 9 | 
             
                  #
         | 
| 10 10 | 
             
                  def symbolize_keys(hash)
         | 
| 11 | 
            -
                    hash. | 
| 11 | 
            +
                    hash.each_with_object({}) do |(k, v), memo|
         | 
| 12 | 
            +
                      memo[k.to_sym] = v
         | 
| 13 | 
            +
                    end
         | 
| 12 14 | 
             
                  end
         | 
| 13 15 |  | 
| 14 16 | 
             
                  # public: Convert hash keys from strings to symbols in place
         | 
| @@ -20,17 +22,18 @@ module Segment | |
| 20 22 | 
             
                  # public: Return a new hash with keys as strings
         | 
| 21 23 | 
             
                  #
         | 
| 22 24 | 
             
                  def stringify_keys(hash)
         | 
| 23 | 
            -
                    hash. | 
| 25 | 
            +
                    hash.each_with_object({}) do |(k, v), memo|
         | 
| 26 | 
            +
                      memo[k.to_s] = v
         | 
| 27 | 
            +
                    end
         | 
| 24 28 | 
             
                  end
         | 
| 25 29 |  | 
| 26 30 | 
             
                  # public: Returns a new hash with all the date values in the into iso8601
         | 
| 27 31 | 
             
                  #         strings
         | 
| 28 32 | 
             
                  #
         | 
| 29 33 | 
             
                  def isoify_dates(hash)
         | 
| 30 | 
            -
                    hash. | 
| 34 | 
            +
                    hash.each_with_object({}) do |(k, v), memo|
         | 
| 31 35 | 
             
                      memo[k] = datetime_in_iso8601(v)
         | 
| 32 | 
            -
             | 
| 33 | 
            -
                    }
         | 
| 36 | 
            +
                    end
         | 
| 34 37 | 
             
                  end
         | 
| 35 38 |  | 
| 36 39 | 
             
                  # public: Converts all the date values in the into iso8601 strings in place
         | 
| @@ -42,18 +45,18 @@ module Segment | |
| 42 45 | 
             
                  # public: Returns a uid string
         | 
| 43 46 | 
             
                  #
         | 
| 44 47 | 
             
                  def uid
         | 
| 45 | 
            -
                    arr = SecureRandom.random_bytes(16).unpack( | 
| 48 | 
            +
                    arr = SecureRandom.random_bytes(16).unpack('NnnnnN')
         | 
| 46 49 | 
             
                    arr[2] = (arr[2] & 0x0fff) | 0x4000
         | 
| 47 50 | 
             
                    arr[3] = (arr[3] & 0x3fff) | 0x8000
         | 
| 48 | 
            -
                     | 
| 51 | 
            +
                    '%08x-%04x-%04x-%04x-%04x%08x' % arr
         | 
| 49 52 | 
             
                  end
         | 
| 50 53 |  | 
| 51 | 
            -
                  def datetime_in_iso8601 | 
| 54 | 
            +
                  def datetime_in_iso8601(datetime)
         | 
| 52 55 | 
             
                    case datetime
         | 
| 53 56 | 
             
                    when Time
         | 
| 54 | 
            -
             | 
| 57 | 
            +
                      time_in_iso8601 datetime
         | 
| 55 58 | 
             
                    when DateTime
         | 
| 56 | 
            -
             | 
| 59 | 
            +
                      time_in_iso8601 datetime.to_time
         | 
| 57 60 | 
             
                    when Date
         | 
| 58 61 | 
             
                      date_in_iso8601 datetime
         | 
| 59 62 | 
             
                    else
         | 
| @@ -61,19 +64,15 @@ module Segment | |
| 61 64 | 
             
                    end
         | 
| 62 65 | 
             
                  end
         | 
| 63 66 |  | 
| 64 | 
            -
                  def time_in_iso8601 | 
| 65 | 
            -
                     | 
| 66 | 
            -
                                 (".%06i" % time.usec)[0, fraction_digits + 1]
         | 
| 67 | 
            -
                               end
         | 
| 68 | 
            -
             | 
| 69 | 
            -
                    "#{time.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction}#{formatted_offset(time, true, 'Z')}"
         | 
| 67 | 
            +
                  def time_in_iso8601(time)
         | 
| 68 | 
            +
                    "#{time.strftime('%Y-%m-%dT%H:%M:%S.%6N')}#{formatted_offset(time, true, 'Z')}"
         | 
| 70 69 | 
             
                  end
         | 
| 71 70 |  | 
| 72 | 
            -
                  def date_in_iso8601 | 
| 73 | 
            -
                    date.strftime( | 
| 71 | 
            +
                  def date_in_iso8601(date)
         | 
| 72 | 
            +
                    date.strftime('%F')
         | 
| 74 73 | 
             
                  end
         | 
| 75 74 |  | 
| 76 | 
            -
                  def formatted_offset | 
| 75 | 
            +
                  def formatted_offset(time, colon = true, alternate_utc_string = nil)
         | 
| 77 76 | 
             
                    time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
         | 
| 78 77 | 
             
                  end
         | 
| 79 78 |  | 
| @@ -1,13 +1,14 @@ | |
| 1 1 | 
             
            require 'segment/analytics/defaults'
         | 
| 2 | 
            +
            require 'segment/analytics/message_batch'
         | 
| 3 | 
            +
            require 'segment/analytics/transport'
         | 
| 2 4 | 
             
            require 'segment/analytics/utils'
         | 
| 3 | 
            -
            require 'segment/analytics/defaults'
         | 
| 4 | 
            -
            require 'segment/analytics/request'
         | 
| 5 5 |  | 
| 6 6 | 
             
            module Segment
         | 
| 7 7 | 
             
              class Analytics
         | 
| 8 8 | 
             
                class Worker
         | 
| 9 9 | 
             
                  include Segment::Analytics::Utils
         | 
| 10 10 | 
             
                  include Segment::Analytics::Defaults
         | 
| 11 | 
            +
                  include Segment::Analytics::Logging
         | 
| 11 12 |  | 
| 12 13 | 
             
                  # public: Creates a new worker
         | 
| 13 14 | 
             
                  #
         | 
| @@ -24,10 +25,11 @@ module Segment | |
| 24 25 | 
             
                    symbolize_keys! options
         | 
| 25 26 | 
             
                    @queue = queue
         | 
| 26 27 | 
             
                    @write_key = write_key
         | 
| 27 | 
            -
                    @ | 
| 28 | 
            -
                     | 
| 29 | 
            -
                    @batch =  | 
| 28 | 
            +
                    @on_error = options[:on_error] || proc { |status, error| }
         | 
| 29 | 
            +
                    batch_size = options[:batch_size] || Defaults::MessageBatch::MAX_SIZE
         | 
| 30 | 
            +
                    @batch = MessageBatch.new(batch_size)
         | 
| 30 31 | 
             
                    @lock = Mutex.new
         | 
| 32 | 
            +
                    @transport = Transport.new(options)
         | 
| 31 33 | 
             
                  end
         | 
| 32 34 |  | 
| 33 35 | 
             
                  # public: Continuously runs the loop to check for new events
         | 
| @@ -37,17 +39,16 @@ module Segment | |
| 37 39 | 
             
                      return if @queue.empty?
         | 
| 38 40 |  | 
| 39 41 | 
             
                      @lock.synchronize do
         | 
| 40 | 
            -
                        until @batch. | 
| 41 | 
            -
                          @batch << @queue.pop
         | 
| 42 | 
            -
                        end
         | 
| 42 | 
            +
                        consume_message_from_queue! until @batch.full? || @queue.empty?
         | 
| 43 43 | 
             
                      end
         | 
| 44 44 |  | 
| 45 | 
            -
                      res =  | 
| 45 | 
            +
                      res = @transport.send @write_key, @batch
         | 
| 46 | 
            +
                      @on_error.call(res.status, res.error) unless res.status == 200
         | 
| 46 47 |  | 
| 47 48 | 
             
                      @lock.synchronize { @batch.clear }
         | 
| 48 | 
            -
             | 
| 49 | 
            -
                      @on_error.call res.status, res.error unless res.status == 200
         | 
| 50 49 | 
             
                    end
         | 
| 50 | 
            +
                  ensure
         | 
| 51 | 
            +
                    @transport.shutdown
         | 
| 51 52 | 
             
                  end
         | 
| 52 53 |  | 
| 53 54 | 
             
                  # public: Check whether we have outstanding requests.
         | 
| @@ -55,6 +56,14 @@ module Segment | |
| 55 56 | 
             
                  def is_requesting?
         | 
| 56 57 | 
             
                    @lock.synchronize { !@batch.empty? }
         | 
| 57 58 | 
             
                  end
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                  private
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                  def consume_message_from_queue!
         | 
| 63 | 
            +
                    @batch << @queue.pop
         | 
| 64 | 
            +
                  rescue MessageBatch::JSONGenerationError => e
         | 
| 65 | 
            +
                    @on_error.call(-1, e.to_s)
         | 
| 66 | 
            +
                  end
         | 
| 58 67 | 
             
                end
         | 
| 59 68 | 
             
              end
         | 
| 60 69 | 
             
            end
         |