segment 2.2.5

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