analytics-ruby 2.2.3.pre → 2.2.4.pre

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.
@@ -6,15 +6,31 @@ module Segment
6
6
  PORT = 443
7
7
  PATH = '/v1/import'
8
8
  SSL = true
9
- HEADERS = { :accept => 'application/json' }
10
- RETRIES = 4
11
- BACKOFF = 30.0
9
+ HEADERS = { 'Accept' => 'application/json',
10
+ 'Content-Type' => 'application/json',
11
+ 'User-Agent' => "analytics-ruby/#{Analytics::VERSION}" }
12
+ RETRIES = 10
12
13
  end
13
14
 
14
15
  module Queue
15
- BATCH_SIZE = 100
16
16
  MAX_SIZE = 10000
17
17
  end
18
+
19
+ module Message
20
+ MAX_BYTES = 32768 # 32Kb
21
+ end
22
+
23
+ module MessageBatch
24
+ MAX_BYTES = 512_000 # 500Kb
25
+ MAX_SIZE = 100
26
+ end
27
+
28
+ module BackoffPolicy
29
+ MIN_TIMEOUT_MS = 100
30
+ MAX_TIMEOUT_MS = 10000
31
+ MULTIPLIER = 1.5
32
+ RANDOMIZATION_FACTOR = 0.5
33
+ end
18
34
  end
19
35
  end
20
36
  end
@@ -14,12 +14,10 @@ module Segment
14
14
  end
15
15
  end
16
16
 
17
- def logger= logger
18
- @logger = logger
19
- end
17
+ attr_writer :logger
20
18
  end
21
19
 
22
- def self.included base
20
+ def self.included(base)
23
21
  class << base
24
22
  def logger
25
23
  Logging.logger
@@ -0,0 +1,26 @@
1
+ require 'segment/analytics/defaults'
2
+
3
+ module Segment
4
+ class Analytics
5
+ # Represents a message to be sent to the API
6
+ class Message
7
+ def initialize(hash)
8
+ @hash = hash
9
+ end
10
+
11
+ def too_big?
12
+ json_size > Defaults::Message::MAX_BYTES
13
+ end
14
+
15
+ def json_size
16
+ to_json.bytesize
17
+ end
18
+
19
+ # Since the hash is expected to not be modified (set at initialization),
20
+ # the JSON version can be cached after the first computation.
21
+ def to_json(*args)
22
+ @json ||= @hash.to_json(*args)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,58 @@
1
+ require 'segment/analytics/logging'
2
+
3
+ module Segment
4
+ class Analytics
5
+ # A batch of `Message`s to be sent to the API
6
+ class MessageBatch
7
+ extend Forwardable
8
+ include Segment::Analytics::Logging
9
+ include Segment::Analytics::Defaults::MessageBatch
10
+
11
+ def initialize(max_message_count)
12
+ @messages = []
13
+ @max_message_count = max_message_count
14
+ @json_size = 0
15
+ end
16
+
17
+ def <<(message)
18
+ if message.too_big?
19
+ logger.error('a message exceeded the maximum allowed size')
20
+ else
21
+ @messages << message
22
+ @json_size += message.json_size + 1 # One byte for the comma
23
+ end
24
+ end
25
+
26
+ def full?
27
+ item_count_exhausted? || size_exhausted?
28
+ end
29
+
30
+ def clear
31
+ @messages.clear
32
+ @json_size = 0
33
+ end
34
+
35
+ def_delegators :@messages, :to_json
36
+ def_delegators :@messages, :empty?
37
+ def_delegators :@messages, :length
38
+
39
+ private
40
+
41
+ def item_count_exhausted?
42
+ @messages.length >= @max_message_count
43
+ end
44
+
45
+ # We consider the max size here as just enough to leave room for one more
46
+ # message of the largest size possible. This is a shortcut that allows us
47
+ # to use a native Ruby `Queue` that doesn't allow peeking. The tradeoff
48
+ # here is that we might fit in less messages than possible into a batch.
49
+ #
50
+ # The alternative is to use our own `Queue` implementation that allows
51
+ # peeking, and to consider the next message size when calculating whether
52
+ # the message can be accomodated in this batch.
53
+ def size_exhausted?
54
+ @json_size >= (MAX_BYTES - Defaults::Message::MAX_BYTES)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -2,6 +2,7 @@ require 'segment/analytics/defaults'
2
2
  require 'segment/analytics/utils'
3
3
  require 'segment/analytics/response'
4
4
  require 'segment/analytics/logging'
5
+ require 'segment/analytics/backoff_policy'
5
6
  require 'net/http'
6
7
  require 'net/https'
7
8
  require 'json'
@@ -19,10 +20,11 @@ module Segment
19
20
  options[:host] ||= HOST
20
21
  options[:port] ||= PORT
21
22
  options[:ssl] ||= SSL
22
- options[:headers] ||= HEADERS
23
+ @headers = options[:headers] || HEADERS
23
24
  @path = options[:path] || PATH
24
25
  @retries = options[:retries] || RETRIES
25
- @backoff = options[:backoff] || BACKOFF
26
+ @backoff_policy =
27
+ options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
26
28
 
27
29
  http = Net::HTTP.new(options[:host], options[:port])
28
30
  http.use_ssl = options[:ssl]
@@ -36,42 +38,92 @@ module Segment
36
38
  #
37
39
  # returns - Response of the status and error if it exists
38
40
  def post(write_key, batch)
39
- status, error = nil, nil
40
- remaining_retries = @retries
41
- backoff = @backoff
42
- headers = { 'Content-Type' => 'application/json', 'accept' => 'application/json' }
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
+
46
+ [Response.new(status_code, error), should_retry]
47
+ end
48
+
49
+ if exception
50
+ logger.error(exception.message)
51
+ exception.backtrace.each { |line| logger.error(line) }
52
+ Response.new(-1, "Connection error: #{exception}")
53
+ else
54
+ last_response
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def should_retry_request?(status_code, body)
61
+ if status_code >= 500
62
+ true # Server error
63
+ elsif status_code == 429
64
+ true # Rate limited
65
+ elsif status_code >= 400
66
+ logger.error(body)
67
+ false # Client error. Do not retry, but log
68
+ else
69
+ false
70
+ end
71
+ end
72
+
73
+ # Takes a block that returns [result, should_retry].
74
+ #
75
+ # Retries upto `retries_remaining` times, if `should_retry` is false or
76
+ # an exception is raised. `@backoff_policy` is used to determine the
77
+ # duration to sleep between attempts
78
+ #
79
+ # Returns [last_result, raised_exception]
80
+ def retry_with_backoff(retries_remaining, &block)
81
+ result, caught_exception = nil
82
+ should_retry = false
83
+
43
84
  begin
44
- payload = JSON.generate :sentAt => datetime_in_iso8601(Time.new), :batch => batch
45
- request = Net::HTTP::Post.new(@path, headers)
46
- request.basic_auth write_key, nil
47
-
48
- if self.class.stub
49
- status = 200
50
- error = nil
51
- logger.debug "stubbed request to #{@path}: write key = #{write_key}, payload = #{payload}"
52
- else
53
- res = @http.request(request, payload)
54
- status = res.code.to_i
55
- body = JSON.parse(res.body)
56
- error = body["error"]
57
- end
58
- rescue Exception => e
59
- unless (remaining_retries -=1).zero?
60
- sleep(backoff)
61
- retry
62
- end
63
-
64
- logger.error e.message
65
- e.backtrace.each { |line| logger.error line }
66
- status = -1
67
- error = "Connection error: #{e}"
85
+ result, should_retry = yield
86
+ return [result, nil] unless should_retry
87
+ rescue StandardError => e
88
+ should_retry = true
89
+ caught_exception = e
90
+ end
91
+
92
+ if should_retry && (retries_remaining > 1)
93
+ sleep(@backoff_policy.next_interval.to_f / 1000)
94
+ retry_with_backoff(retries_remaining - 1, &block)
95
+ else
96
+ [result, caught_exception]
68
97
  end
98
+ end
99
+
100
+ # Sends a request for the batch, returns [status_code, body]
101
+ def send_request(write_key, batch)
102
+ payload = JSON.generate(
103
+ :sentAt => datetime_in_iso8601(Time.now),
104
+ :batch => batch
105
+ )
106
+ request = Net::HTTP::Post.new(@path, @headers)
107
+ request.basic_auth(write_key, nil)
108
+
109
+ if self.class.stub
110
+ logger.debug "stubbed request to #{@path}: " \
111
+ "write key = #{write_key}, batch = JSON.generate(#{batch})"
69
112
 
70
- Response.new status, error
113
+ [200, '{}']
114
+ else
115
+ # If `start` is not called, Ruby adds a 'Connection: close' header to
116
+ # all requests, preventing us from reusing a connection for multiple
117
+ # HTTP requests
118
+ @http.start unless @http.started?
119
+
120
+ response = @http.request(request, payload)
121
+ [response.code.to_i, response.body]
122
+ end
71
123
  end
72
124
 
73
125
  class << self
74
- attr_accessor :stub
126
+ attr_writer :stub
75
127
 
76
128
  def stub
77
129
  @stub || ENV['STUB']
@@ -13,4 +13,3 @@ module Segment
13
13
  end
14
14
  end
15
15
  end
16
-
@@ -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,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("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
56
  when Time
54
- time_in_iso8601 datetime
57
+ time_in_iso8601 datetime
55
58
  when DateTime
56
- time_in_iso8601 datetime.to_time
59
+ time_in_iso8601 datetime.to_time
57
60
  when Date
58
61
  date_in_iso8601 datetime
59
62
  else
@@ -61,19 +64,19 @@ module Segment
61
64
  end
62
65
  end
63
66
 
64
- def time_in_iso8601 time, fraction_digits = 3
67
+ def time_in_iso8601(time, fraction_digits = 3)
65
68
  fraction = if fraction_digits > 0
66
- (".%06i" % time.usec)[0, fraction_digits + 1]
69
+ ('.%06i' % time.usec)[0, fraction_digits + 1]
67
70
  end
68
71
 
69
- "#{time.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction}#{formatted_offset(time, true, 'Z')}"
72
+ "#{time.strftime('%Y-%m-%dT%H:%M:%S')}#{fraction}#{formatted_offset(time, true, 'Z')}"
70
73
  end
71
74
 
72
- def date_in_iso8601 date
73
- date.strftime("%F")
75
+ def date_in_iso8601(date)
76
+ date.strftime('%F')
74
77
  end
75
78
 
76
- def formatted_offset time, colon = true, alternate_utc_string = nil
79
+ def formatted_offset(time, colon = true, alternate_utc_string = nil)
77
80
  time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
78
81
  end
79
82
 
@@ -1,5 +1,5 @@
1
1
  module Segment
2
2
  class Analytics
3
- VERSION = '2.2.3.pre'
3
+ VERSION = '2.2.4.pre'
4
4
  end
5
5
  end
@@ -1,7 +1,8 @@
1
1
  require 'segment/analytics/defaults'
2
- require 'segment/analytics/utils'
3
- require 'segment/analytics/defaults'
2
+ require 'segment/analytics/message'
3
+ require 'segment/analytics/message_batch'
4
4
  require 'segment/analytics/request'
5
+ require 'segment/analytics/utils'
5
6
 
6
7
  module Segment
7
8
  class Analytics
@@ -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
+ @request = Request.new
31
33
  end
32
34
 
33
35
  # public: Continuously runs the loop to check for new events
@@ -37,14 +39,13 @@ 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
+ until @batch.full? || @queue.empty?
43
+ @batch << Message.new(@queue.pop)
42
44
  end
43
45
  end
44
46
 
45
- res = Request.new.post @write_key, @batch
46
-
47
- @on_error.call res.status, res.error unless res.status == 200
47
+ res = @request.post(@write_key, @batch)
48
+ @on_error.call(res.status, res.error) unless res.status == 200
48
49
 
49
50
  @lock.synchronize { @batch.clear }
50
51
  end
@@ -0,0 +1,38 @@
1
+ require 'faraday'
2
+ require 'pmap'
3
+
4
+ class RunscopeClient
5
+ def initialize(api_token)
6
+ headers = { 'Authorization' => "Bearer #{api_token}" }
7
+ @conn = Faraday.new('https://api.runscope.com', headers: headers)
8
+ end
9
+
10
+ def requests(bucket_key)
11
+ with_retries(3) do
12
+ response = @conn.get("/buckets/#{bucket_key}/messages", count: 20)
13
+
14
+ raise "Runscope error. #{response.body}" unless response.status == 200
15
+
16
+ message_uuids = JSON.parse(response.body)['data'].map { |message|
17
+ message.fetch('uuid')
18
+ }
19
+
20
+ message_uuids.pmap { |uuid|
21
+ response = @conn.get("/buckets/#{bucket_key}/messages/#{uuid}")
22
+ raise "Runscope error. #{response.body}" unless response.status == 200
23
+ JSON.parse(response.body).fetch('data').fetch('request')
24
+ }
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def with_retries(max_retries)
31
+ retries ||= 0
32
+ yield
33
+ rescue StandardError => e
34
+ retries += 1
35
+ retry if retries < max_retries
36
+ raise e
37
+ end
38
+ end