analytics-ruby 2.0.5 → 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.
@@ -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 ||= if defined?(Rails)
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
- def logger= logger
18
- @logger = logger
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 base
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
@@ -13,4 +13,3 @@ module Segment
13
13
  end
14
14
  end
15
15
  end
16
-
@@ -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.inject({}) { |memo, (k,v)| memo[k.to_sym] = v; memo }
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.inject({}) { |memo, (k,v)| memo[k.to_s] = v; memo }
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.inject({}) { |memo, (k, v)|
34
+ hash.each_with_object({}) do |(k, v), memo|
31
35
  memo[k] = datetime_in_iso8601(v)
32
- memo
33
- }
36
+ end
34
37
  end
35
38
 
36
39
  # public: Converts all the date values in the into iso8601 strings in place
@@ -42,16 +45,18 @@ module Segment
42
45
  # public: Returns a uid string
43
46
  #
44
47
  def uid
45
- arr = SecureRandom.random_bytes(16).unpack("NnnnnN")
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
- "%08x-%04x-%04x-%04x-%04x%08x" % arr
51
+ '%08x-%04x-%04x-%04x-%04x%08x' % arr
49
52
  end
50
53
 
51
- def datetime_in_iso8601 datetime
54
+ def datetime_in_iso8601(datetime)
52
55
  case datetime
53
- when Time, DateTime
54
- time_in_iso8601 datetime
56
+ when Time
57
+ time_in_iso8601 datetime
58
+ when DateTime
59
+ time_in_iso8601 datetime.to_time
55
60
  when Date
56
61
  date_in_iso8601 datetime
57
62
  else
@@ -59,19 +64,15 @@ module Segment
59
64
  end
60
65
  end
61
66
 
62
- def time_in_iso8601 time, fraction_digits = 0
63
- fraction = if fraction_digits > 0
64
- (".%06i" % time.usec)[0, fraction_digits + 1]
65
- end
66
-
67
- "#{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')}"
68
69
  end
69
70
 
70
- def date_in_iso8601 date
71
- date.strftime("%F")
71
+ def date_in_iso8601(date)
72
+ date.strftime('%F')
72
73
  end
73
74
 
74
- def formatted_offset time, colon = true, alternate_utc_string = nil
75
+ def formatted_offset(time, colon = true, alternate_utc_string = nil)
75
76
  time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
76
77
  end
77
78
 
@@ -1,5 +1,5 @@
1
1
  module Segment
2
2
  class Analytics
3
- VERSION = '2.0.5'
3
+ VERSION = '2.4.0'
4
4
  end
5
5
  end
@@ -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
- @batch_size = options[:batch_size] || Queue::BATCH_SIZE
28
- @on_error = options[:on_error] || Proc.new { |status, error| }
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,16 +39,16 @@ module Segment
37
39
  return if @queue.empty?
38
40
 
39
41
  @lock.synchronize do
40
- until @batch.length >= @batch_size || @queue.empty?
41
- @batch << @queue.pop
42
- end
42
+ consume_message_from_queue! until @batch.full? || @queue.empty?
43
43
  end
44
44
 
45
- res = Request.new.post @write_key, @batch
46
- @on_error.call res.status, res.error unless res.status == 200
45
+ res = @transport.send @write_key, @batch
46
+ @on_error.call(res.status, res.error) unless res.status == 200
47
47
 
48
48
  @lock.synchronize { @batch.clear }
49
49
  end
50
+ ensure
51
+ @transport.shutdown
50
52
  end
51
53
 
52
54
  # public: Check whether we have outstanding requests.
@@ -54,6 +56,14 @@ module Segment
54
56
  def is_requesting?
55
57
  @lock.synchronize { !@batch.empty? }
56
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
57
67
  end
58
68
  end
59
69
  end