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.
- checksums.yaml +7 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +89 -0
- data/History.md +222 -0
- data/Makefile +17 -0
- data/README.md +84 -0
- data/RELEASING.md +9 -0
- data/Rakefile +23 -0
- data/analytics-ruby.gemspec +33 -0
- data/bin/analytics +93 -0
- data/codecov.yml +2 -0
- data/lib/analytics-ruby.rb +1 -0
- data/lib/segment.rb +1 -0
- data/lib/segment/analytics.rb +38 -0
- data/lib/segment/analytics/backoff_policy.rb +49 -0
- data/lib/segment/analytics/client.rb +425 -0
- data/lib/segment/analytics/defaults.rb +36 -0
- data/lib/segment/analytics/logging.rb +33 -0
- data/lib/segment/analytics/message.rb +26 -0
- data/lib/segment/analytics/message_batch.rb +59 -0
- data/lib/segment/analytics/request.rb +134 -0
- data/lib/segment/analytics/response.rb +15 -0
- data/lib/segment/analytics/utils.rb +91 -0
- data/lib/segment/analytics/version.rb +5 -0
- data/lib/segment/analytics/worker.rb +61 -0
- data/spec/helpers/runscope_client.rb +38 -0
- data/spec/segment/analytics/backoff_policy_spec.rb +92 -0
- data/spec/segment/analytics/client_spec.rb +328 -0
- data/spec/segment/analytics/e2e_spec.rb +48 -0
- data/spec/segment/analytics/message_batch_spec.rb +49 -0
- data/spec/segment/analytics/message_spec.rb +35 -0
- data/spec/segment/analytics/request_spec.rb +244 -0
- data/spec/segment/analytics/response_spec.rb +30 -0
- data/spec/segment/analytics/worker_spec.rb +110 -0
- data/spec/segment/analytics_spec.rb +120 -0
- data/spec/spec_helper.rb +128 -0
- metadata +205 -0
@@ -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,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
|