segment 2.2.5

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,36 @@
1
+ module Segment
2
+ class Analytics
3
+ module Defaults
4
+ module Request
5
+ HOST = 'api.segment.io'
6
+ PORT = 443
7
+ PATH = '/v1/import'
8
+ SSL = true
9
+ HEADERS = { 'Accept' => 'application/json',
10
+ 'Content-Type' => 'application/json',
11
+ 'User-Agent' => "analytics-ruby/#{Analytics::VERSION}" }
12
+ RETRIES = 10
13
+ end
14
+
15
+ module Queue
16
+ MAX_SIZE = 10000
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
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ require 'logger'
2
+
3
+ module Segment
4
+ class Analytics
5
+ module Logging
6
+ class << self
7
+ 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
16
+
17
+ attr_writer :logger
18
+ end
19
+
20
+ def self.included(base)
21
+ class << base
22
+ def logger
23
+ Logging.logger
24
+ end
25
+ end
26
+ end
27
+
28
+ def logger
29
+ Logging.logger
30
+ end
31
+ end
32
+ end
33
+ end
@@ -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,59 @@
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
+ extend Forwardable
9
+ include Segment::Analytics::Logging
10
+ include Segment::Analytics::Defaults::MessageBatch
11
+
12
+ def initialize(max_message_count)
13
+ @messages = []
14
+ @max_message_count = max_message_count
15
+ @json_size = 0
16
+ end
17
+
18
+ def <<(message)
19
+ if message.too_big?
20
+ logger.error('a message exceeded the maximum allowed size')
21
+ else
22
+ @messages << message
23
+ @json_size += message.json_size + 1 # One byte for the comma
24
+ end
25
+ end
26
+
27
+ def full?
28
+ item_count_exhausted? || size_exhausted?
29
+ end
30
+
31
+ def clear
32
+ @messages.clear
33
+ @json_size = 0
34
+ end
35
+
36
+ def_delegators :@messages, :to_json
37
+ def_delegators :@messages, :empty?
38
+ def_delegators :@messages, :length
39
+
40
+ private
41
+
42
+ def item_count_exhausted?
43
+ @messages.length >= @max_message_count
44
+ end
45
+
46
+ # We consider the max size here as just enough to leave room for one more
47
+ # message of the largest size possible. This is a shortcut that allows us
48
+ # to use a native Ruby `Queue` that doesn't allow peeking. The tradeoff
49
+ # here is that we might fit in less messages than possible into a batch.
50
+ #
51
+ # The alternative is to use our own `Queue` implementation that allows
52
+ # peeking, and to consider the next message size when calculating whether
53
+ # the message can be accomodated in this batch.
54
+ def size_exhausted?
55
+ @json_size >= (MAX_BYTES - Defaults::Message::MAX_BYTES)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,134 @@
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 Request
13
+ include Segment::Analytics::Defaults::Request
14
+ include Segment::Analytics::Utils
15
+ include Segment::Analytics::Logging
16
+
17
+ # public: Creates a new request object to send analytics batch
18
+ #
19
+ def initialize(options = {})
20
+ options[:host] ||= HOST
21
+ options[:port] ||= PORT
22
+ options[:ssl] ||= SSL
23
+ @headers = options[:headers] || HEADERS
24
+ @path = options[:path] || PATH
25
+ @retries = options[:retries] || RETRIES
26
+ @backoff_policy =
27
+ options[:backoff_policy] || Segment::Analytics::BackoffPolicy.new
28
+
29
+ http = Net::HTTP.new(options[:host], options[:port])
30
+ http.use_ssl = options[:ssl]
31
+ http.read_timeout = 8
32
+ http.open_timeout = 4
33
+
34
+ @http = http
35
+ end
36
+
37
+ # public: Posts the write key and batch of messages to the API.
38
+ #
39
+ # returns - Response of the status and error if it exists
40
+ def post(write_key, batch)
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
+
84
+ begin
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]
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})"
112
+
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
123
+ end
124
+
125
+ class << self
126
+ attr_writer :stub
127
+
128
+ def stub
129
+ @stub || ENV['STUB']
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,15 @@
1
+ module Segment
2
+ class Analytics
3
+ class Response
4
+ attr_reader :status, :error
5
+
6
+ # public: Simple class to wrap responses from the API
7
+ #
8
+ #
9
+ def initialize(status = 200, error = nil)
10
+ @status = status
11
+ @error = error
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,91 @@
1
+ require 'securerandom'
2
+
3
+ module Segment
4
+ class Analytics
5
+ module Utils
6
+ extend self
7
+
8
+ # public: Return a new hash with keys converted from strings to symbols
9
+ #
10
+ def symbolize_keys(hash)
11
+ hash.each_with_object({}) do |(k, v), memo|
12
+ memo[k.to_sym] = v
13
+ end
14
+ end
15
+
16
+ # public: Convert hash keys from strings to symbols in place
17
+ #
18
+ def symbolize_keys!(hash)
19
+ hash.replace symbolize_keys hash
20
+ end
21
+
22
+ # public: Return a new hash with keys as strings
23
+ #
24
+ def stringify_keys(hash)
25
+ hash.each_with_object({}) do |(k, v), memo|
26
+ memo[k.to_s] = v
27
+ end
28
+ end
29
+
30
+ # public: Returns a new hash with all the date values in the into iso8601
31
+ # strings
32
+ #
33
+ def isoify_dates(hash)
34
+ hash.each_with_object({}) do |(k, v), memo|
35
+ memo[k] = datetime_in_iso8601(v)
36
+ end
37
+ end
38
+
39
+ # public: Converts all the date values in the into iso8601 strings in place
40
+ #
41
+ def isoify_dates!(hash)
42
+ hash.replace isoify_dates hash
43
+ end
44
+
45
+ # public: Returns a uid string
46
+ #
47
+ def uid
48
+ arr = SecureRandom.random_bytes(16).unpack('NnnnnN')
49
+ arr[2] = (arr[2] & 0x0fff) | 0x4000
50
+ arr[3] = (arr[3] & 0x3fff) | 0x8000
51
+ '%08x-%04x-%04x-%04x-%04x%08x' % arr
52
+ end
53
+
54
+ def datetime_in_iso8601(datetime)
55
+ case datetime
56
+ when Time
57
+ time_in_iso8601 datetime
58
+ when DateTime
59
+ time_in_iso8601 datetime.to_time
60
+ when Date
61
+ date_in_iso8601 datetime
62
+ else
63
+ datetime
64
+ end
65
+ end
66
+
67
+ def time_in_iso8601(time, fraction_digits = 3)
68
+ fraction = if fraction_digits > 0
69
+ ('.%06i' % time.usec)[0, fraction_digits + 1]
70
+ end
71
+
72
+ "#{time.strftime('%Y-%m-%dT%H:%M:%S')}#{fraction}#{formatted_offset(time, true, 'Z')}"
73
+ end
74
+
75
+ def date_in_iso8601(date)
76
+ date.strftime('%F')
77
+ end
78
+
79
+ def formatted_offset(time, colon = true, alternate_utc_string = nil)
80
+ time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
81
+ end
82
+
83
+ def seconds_to_utc_offset(seconds, colon = true)
84
+ (colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON) % [(seconds < 0 ? '-' : '+'), (seconds.abs / 3600), ((seconds.abs % 3600) / 60)]
85
+ end
86
+
87
+ UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
88
+ UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ module Segment
2
+ class Analytics
3
+ VERSION = '2.2.5'
4
+ end
5
+ end
@@ -0,0 +1,61 @@
1
+ require 'segment/analytics/defaults'
2
+ require 'segment/analytics/message'
3
+ require 'segment/analytics/message_batch'
4
+ require 'segment/analytics/request'
5
+ require 'segment/analytics/utils'
6
+
7
+ module Segment
8
+ class Analytics
9
+ class Worker
10
+ include Segment::Analytics::Utils
11
+ include Segment::Analytics::Defaults
12
+
13
+ # public: Creates a new worker
14
+ #
15
+ # The worker continuously takes messages off the queue
16
+ # and makes requests to the segment.io api
17
+ #
18
+ # queue - Queue synchronized between client and worker
19
+ # write_key - String of the project's Write key
20
+ # options - Hash of worker options
21
+ # batch_size - Fixnum of how many items to send in a batch
22
+ # on_error - Proc of what to do on an error
23
+ #
24
+ def initialize(queue, write_key, options = {})
25
+ symbolize_keys! options
26
+ @queue = queue
27
+ @write_key = write_key
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)
31
+ @lock = Mutex.new
32
+ @request = Request.new
33
+ end
34
+
35
+ # public: Continuously runs the loop to check for new events
36
+ #
37
+ def run
38
+ until Thread.current[:should_exit]
39
+ return if @queue.empty?
40
+
41
+ @lock.synchronize do
42
+ until @batch.full? || @queue.empty?
43
+ @batch << Message.new(@queue.pop)
44
+ end
45
+ end
46
+
47
+ res = @request.post(@write_key, @batch)
48
+ @on_error.call(res.status, res.error) unless res.status == 200
49
+
50
+ @lock.synchronize { @batch.clear }
51
+ end
52
+ end
53
+
54
+ # public: Check whether we have outstanding requests.
55
+ #
56
+ def is_requesting?
57
+ @lock.synchronize { !@batch.empty? }
58
+ end
59
+ end
60
+ end
61
+ end