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