analytics-ruby 2.0.5 → 2.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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