analytics-ruby 1.1.0 → 2.0.1
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.
- data/Gemfile.lock +47 -0
- data/analytics-ruby.gemspec +5 -7
- data/lib/segment.rb +1 -0
- data/lib/segment/analytics.rb +31 -0
- data/lib/segment/analytics/client.rb +335 -0
- data/lib/segment/analytics/defaults.rb +20 -0
- data/lib/segment/analytics/logging.rb +35 -0
- data/lib/segment/analytics/request.rb +80 -0
- data/lib/segment/analytics/response.rb +16 -0
- data/lib/segment/analytics/utils.rb +85 -0
- data/lib/segment/analytics/version.rb +6 -0
- data/lib/segment/analytics/worker.rb +59 -0
- data/spec/segment/analytics/client_spec.rb +248 -0
- data/spec/segment/analytics/worker_spec.rb +96 -0
- data/spec/segment/analytics_spec.rb +103 -0
- data/spec/spec_helper.rb +60 -55
- data/tags +135 -0
- metadata +34 -31
- data/analytics-ruby-0.6.0.gem +0 -0
- data/analytics-ruby-1.0.0.gem +0 -0
- data/lib/analytics-ruby.rb +0 -51
- data/lib/analytics-ruby/client.rb +0 -311
- data/lib/analytics-ruby/consumer.rb +0 -76
- data/lib/analytics-ruby/defaults.rb +0 -21
- data/lib/analytics-ruby/request.rb +0 -61
- data/lib/analytics-ruby/response.rb +0 -17
- data/lib/analytics-ruby/util.rb +0 -42
- data/lib/analytics-ruby/version.rb +0 -3
- data/spec/client_spec.rb +0 -212
- data/spec/consumer_spec.rb +0 -103
- data/spec/module_spec.rb +0 -136
@@ -0,0 +1,35 @@
|
|
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
|
+
def logger= logger
|
18
|
+
@logger = logger
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.included base
|
23
|
+
class << base
|
24
|
+
def logger
|
25
|
+
Logging.logger
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def logger
|
31
|
+
Logging.logger
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'segment/analytics/defaults'
|
2
|
+
require 'segment/analytics/utils'
|
3
|
+
require 'segment/analytics/response'
|
4
|
+
require 'segment/analytics/logging'
|
5
|
+
require 'net/http'
|
6
|
+
require 'net/https'
|
7
|
+
require 'json'
|
8
|
+
|
9
|
+
module Segment
|
10
|
+
class Analytics
|
11
|
+
class Request
|
12
|
+
include Segment::Analytics::Defaults::Request
|
13
|
+
include Segment::Analytics::Utils
|
14
|
+
include Segment::Analytics::Logging
|
15
|
+
|
16
|
+
# public: Creates a new request object to send analytics batch
|
17
|
+
#
|
18
|
+
def initialize(options = {})
|
19
|
+
options[:host] ||= HOST
|
20
|
+
options[:port] ||= PORT
|
21
|
+
options[:ssl] ||= SSL
|
22
|
+
options[:headers] ||= HEADERS
|
23
|
+
@path = options[:path] || PATH
|
24
|
+
@retries = options[:retries] || RETRIES
|
25
|
+
@backoff = options[:backoff] || BACKOFF
|
26
|
+
|
27
|
+
http = Net::HTTP.new(options[:host], options[:port])
|
28
|
+
http.use_ssl = options[:ssl]
|
29
|
+
http.read_timeout = 8
|
30
|
+
http.open_timeout = 4
|
31
|
+
|
32
|
+
@http = http
|
33
|
+
end
|
34
|
+
|
35
|
+
# public: Posts the write key and batch of messages to the API.
|
36
|
+
#
|
37
|
+
# returns - Response of the status and error if it exists
|
38
|
+
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' }
|
43
|
+
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
|
+
|
59
|
+
rescue Exception => e
|
60
|
+
logger.error e.message
|
61
|
+
e.backtrace.each { |line| logger.error line }
|
62
|
+
status = -1
|
63
|
+
error = "Connection error: #{e}"
|
64
|
+
logger.info "retries remaining: #{remaining_retries}"
|
65
|
+
|
66
|
+
unless (remaining_retries -=1).zero?
|
67
|
+
sleep(backoff)
|
68
|
+
retry
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
Response.new status, error
|
73
|
+
end
|
74
|
+
|
75
|
+
class << self
|
76
|
+
attr_accessor :stub
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,16 @@
|
|
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
|
16
|
+
|
@@ -0,0 +1,85 @@
|
|
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.inject({}) { |memo, (k,v)| memo[k.to_sym] = v; memo }
|
12
|
+
end
|
13
|
+
|
14
|
+
# public: Convert hash keys from strings to symbols in place
|
15
|
+
#
|
16
|
+
def symbolize_keys!(hash)
|
17
|
+
hash.replace symbolize_keys hash
|
18
|
+
end
|
19
|
+
|
20
|
+
# public: Return a new hash with keys as strings
|
21
|
+
#
|
22
|
+
def stringify_keys(hash)
|
23
|
+
hash.inject({}) { |memo, (k,v)| memo[k.to_s] = v; memo }
|
24
|
+
end
|
25
|
+
|
26
|
+
# public: Returns a new hash with all the date values in the into iso8601
|
27
|
+
# strings
|
28
|
+
#
|
29
|
+
def isoify_dates(hash)
|
30
|
+
hash.inject({}) { |memo, (k, v)|
|
31
|
+
memo[k] = datetime_in_iso8601(v)
|
32
|
+
memo
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
# public: Converts all the date values in the into iso8601 strings in place
|
37
|
+
#
|
38
|
+
def isoify_dates!(hash)
|
39
|
+
hash.replace isoify_dates hash
|
40
|
+
end
|
41
|
+
|
42
|
+
# public: Returns a uid string
|
43
|
+
#
|
44
|
+
def uid
|
45
|
+
arr = SecureRandom.random_bytes(16).unpack("NnnnnN")
|
46
|
+
arr[2] = (arr[2] & 0x0fff) | 0x4000
|
47
|
+
arr[3] = (arr[3] & 0x3fff) | 0x8000
|
48
|
+
"%08x-%04x-%04x-%04x-%04x%08x" % arr
|
49
|
+
end
|
50
|
+
|
51
|
+
def datetime_in_iso8601 datetime
|
52
|
+
if datetime.is_a? Date
|
53
|
+
date_in_iso8601 datetime
|
54
|
+
elsif datetime.is_a? Time
|
55
|
+
time_in_iso8601 datetime
|
56
|
+
else
|
57
|
+
datetime
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def time_in_iso8601 time, fraction_digits = 0
|
62
|
+
fraction = if fraction_digits > 0
|
63
|
+
(".%06i" % time.usec)[0, fraction_digits + 1]
|
64
|
+
end
|
65
|
+
|
66
|
+
"#{time.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction}#{formatted_offset(time, true, 'Z')}"
|
67
|
+
end
|
68
|
+
|
69
|
+
def date_in_iso8601 date
|
70
|
+
date.strftime("%F")
|
71
|
+
end
|
72
|
+
|
73
|
+
def formatted_offset time, colon = true, alternate_utc_string = nil
|
74
|
+
time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
|
75
|
+
end
|
76
|
+
|
77
|
+
def seconds_to_utc_offset(seconds, colon = true)
|
78
|
+
(colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON) % [(seconds < 0 ? '-' : '+'), seconds.abs, (seconds.abs % 3600)]
|
79
|
+
end
|
80
|
+
|
81
|
+
UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
|
82
|
+
UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'segment/analytics/defaults'
|
2
|
+
require 'segment/analytics/utils'
|
3
|
+
require 'segment/analytics/defaults'
|
4
|
+
require 'segment/analytics/request'
|
5
|
+
|
6
|
+
module Segment
|
7
|
+
class Analytics
|
8
|
+
class Worker
|
9
|
+
include Segment::Analytics::Utils
|
10
|
+
include Segment::Analytics::Defaults
|
11
|
+
|
12
|
+
# public: Creates a new worker
|
13
|
+
#
|
14
|
+
# The worker continuously takes messages off the queue
|
15
|
+
# and makes requests to the segment.io api
|
16
|
+
#
|
17
|
+
# queue - Queue synchronized between client and worker
|
18
|
+
# write_key - String of the project's Write key
|
19
|
+
# options - Hash of worker options
|
20
|
+
# batch_size - Fixnum of how many items to send in a batch
|
21
|
+
# on_error - Proc of what to do on an error
|
22
|
+
#
|
23
|
+
def initialize(queue, write_key, options = {})
|
24
|
+
symbolize_keys! options
|
25
|
+
@queue = queue
|
26
|
+
@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 = []
|
30
|
+
@lock = Mutex.new
|
31
|
+
end
|
32
|
+
|
33
|
+
# public: Continuously runs the loop to check for new events
|
34
|
+
#
|
35
|
+
def run
|
36
|
+
until Thread.current[:should_exit]
|
37
|
+
return if @queue.empty?
|
38
|
+
|
39
|
+
@lock.synchronize do
|
40
|
+
until @batch.length >= @batch_size || @queue.empty?
|
41
|
+
@batch << @queue.pop
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
res = Request.new.post @write_key, @batch
|
46
|
+
@on_error.call res.status, res.error unless res.status == 200
|
47
|
+
|
48
|
+
@lock.synchronize { @batch.clear }
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# public: Check whether we have outstanding requests.
|
53
|
+
#
|
54
|
+
def is_requesting?
|
55
|
+
@lock.synchronize { !@batch.empty? }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,248 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Segment
|
4
|
+
class Analytics
|
5
|
+
describe Client do
|
6
|
+
describe '#initialize' do
|
7
|
+
it 'should error if no write_key is supplied' do
|
8
|
+
expect { Client.new }.to raise_error(ArgumentError)
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'should not error if a write_key is supplied' do
|
12
|
+
Client.new :write_key => WRITE_KEY
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'should not error if a write_key is supplied as a string' do
|
16
|
+
Client.new 'write_key' => WRITE_KEY
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe '#track' do
|
21
|
+
before(:all) do
|
22
|
+
@client = Client.new :write_key => WRITE_KEY
|
23
|
+
@queue = @client.instance_variable_get :@queue
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'should error without an event' do
|
27
|
+
expect { @client.track(:user_id => 'user') }.to raise_error(ArgumentError)
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'should error without a user_id' do
|
31
|
+
expect { @client.track(:event => 'Event') }.to raise_error(ArgumentError)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should error if properties is not a hash' do
|
35
|
+
expect {
|
36
|
+
@client.track({
|
37
|
+
:user_id => 'user',
|
38
|
+
:event => 'Event',
|
39
|
+
:properties => [1,2,3]
|
40
|
+
})
|
41
|
+
}.to raise_error(ArgumentError)
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'should not error with the required options' do
|
45
|
+
@client.track Queued::TRACK
|
46
|
+
@queue.pop
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'should not error when given string keys' do
|
50
|
+
@client.track Utils.stringify_keys(Queued::TRACK)
|
51
|
+
@queue.pop
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'should convert Time properties into iso8601 format' do
|
55
|
+
@client.track({
|
56
|
+
:user_id => 'user',
|
57
|
+
:event => 'Event',
|
58
|
+
:properties => {
|
59
|
+
:time => Time.utc(2013),
|
60
|
+
:nottime => 'x'
|
61
|
+
}
|
62
|
+
})
|
63
|
+
message = @queue.pop
|
64
|
+
message[:properties][:time].should == '2013-01-01T00:00:00Z'
|
65
|
+
message[:properties][:nottime].should == 'x'
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
|
70
|
+
describe '#identify' do
|
71
|
+
before(:all) do
|
72
|
+
@client = Client.new :write_key => WRITE_KEY
|
73
|
+
@queue = @client.instance_variable_get :@queue
|
74
|
+
end
|
75
|
+
|
76
|
+
it 'should error without any user id' do
|
77
|
+
expect { @client.identify({}) }.to raise_error(ArgumentError)
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'should not error with the required options' do
|
81
|
+
@client.identify Queued::IDENTIFY
|
82
|
+
@queue.pop
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'should not error with the required options as strings' do
|
86
|
+
@client.identify Utils.stringify_keys(Queued::IDENTIFY)
|
87
|
+
@queue.pop
|
88
|
+
end
|
89
|
+
|
90
|
+
it 'should convert Time traits into iso8601 format' do
|
91
|
+
@client.identify({
|
92
|
+
:user_id => 'user',
|
93
|
+
:traits => {
|
94
|
+
:time => Time.utc(2013),
|
95
|
+
:nottime => 'x'
|
96
|
+
}
|
97
|
+
})
|
98
|
+
message = @queue.pop
|
99
|
+
message[:traits][:time].should == '2013-01-01T00:00:00Z'
|
100
|
+
message[:traits][:nottime].should == 'x'
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
describe '#alias' do
|
105
|
+
before :all do
|
106
|
+
@client = Client.new :write_key => WRITE_KEY
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'should error without from' do
|
110
|
+
expect { @client.alias :user_id => 1234 }.to raise_error(ArgumentError)
|
111
|
+
end
|
112
|
+
|
113
|
+
it 'should error without to' do
|
114
|
+
expect { @client.alias :previous_id => 1234 }.to raise_error(ArgumentError)
|
115
|
+
end
|
116
|
+
|
117
|
+
it 'should not error with the required options' do
|
118
|
+
@client.alias ALIAS
|
119
|
+
end
|
120
|
+
|
121
|
+
it 'should not error with the required options as strings' do
|
122
|
+
@client.alias Utils.stringify_keys(ALIAS)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
describe '#group' do
|
127
|
+
before :all do
|
128
|
+
@client = Client.new :write_key => WRITE_KEY
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'should error without group_id' do
|
132
|
+
expect { @client.group :user_id => 'foo' }.to raise_error(ArgumentError)
|
133
|
+
end
|
134
|
+
|
135
|
+
it 'should error without user_id' do
|
136
|
+
expect { @client.group :group_id => 'foo' }.to raise_error(ArgumentError)
|
137
|
+
end
|
138
|
+
|
139
|
+
it 'should not error with the required options' do
|
140
|
+
@client.group Queued::GROUP
|
141
|
+
end
|
142
|
+
|
143
|
+
it 'should not error with the required options as strings' do
|
144
|
+
@client.group Utils.stringify_keys(Queued::GROUP)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
describe '#page' do
|
149
|
+
before :all do
|
150
|
+
@client = Client.new :write_key => WRITE_KEY
|
151
|
+
end
|
152
|
+
|
153
|
+
it 'should error without user_id' do
|
154
|
+
expect { @client.page :name => 'foo' }.to raise_error(ArgumentError)
|
155
|
+
end
|
156
|
+
|
157
|
+
it 'should error without name' do
|
158
|
+
expect { @client.page :user_id => 1 }.to raise_error(ArgumentError)
|
159
|
+
end
|
160
|
+
|
161
|
+
it 'should not error with the required options' do
|
162
|
+
@client.page Queued::PAGE
|
163
|
+
end
|
164
|
+
|
165
|
+
it 'should not error with the required options as strings' do
|
166
|
+
@client.page Utils.stringify_keys(Queued::PAGE)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
describe '#screen' do
|
171
|
+
before :all do
|
172
|
+
@client = Client.new :write_key => WRITE_KEY
|
173
|
+
end
|
174
|
+
|
175
|
+
it 'should error without user_id' do
|
176
|
+
expect { @client.screen :name => 'foo' }.to raise_error(ArgumentError)
|
177
|
+
end
|
178
|
+
|
179
|
+
it 'should error without name' do
|
180
|
+
expect { A@client.screen :user_id => 1 }.to raise_error(ArgumentError)
|
181
|
+
end
|
182
|
+
|
183
|
+
it 'should not error with the required options' do
|
184
|
+
@client.screen Queued::SCREEN
|
185
|
+
end
|
186
|
+
|
187
|
+
it 'should not error with the required options as strings' do
|
188
|
+
@client.screen Utils.stringify_keys(Queued::SCREEN)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
describe '#flush' do
|
193
|
+
before(:all) do
|
194
|
+
@client = Client.new :write_key => WRITE_KEY
|
195
|
+
end
|
196
|
+
|
197
|
+
it 'should wait for the queue to finish on a flush' do
|
198
|
+
@client.identify Queued::IDENTIFY
|
199
|
+
@client.track Queued::TRACK
|
200
|
+
@client.flush
|
201
|
+
@client.queued_messages.should == 0
|
202
|
+
end
|
203
|
+
|
204
|
+
it 'should complete when the process forks' do
|
205
|
+
@client.identify Queued::IDENTIFY
|
206
|
+
|
207
|
+
Process.fork do
|
208
|
+
@client.track Queued::TRACK
|
209
|
+
@client.flush
|
210
|
+
@client.queued_messages.should == 0
|
211
|
+
end
|
212
|
+
|
213
|
+
Process.wait
|
214
|
+
end unless defined? JRUBY_VERSION
|
215
|
+
end
|
216
|
+
|
217
|
+
context 'common' do
|
218
|
+
check_property = proc { |msg, k, v| msg[k] && msg[k].should == v }
|
219
|
+
|
220
|
+
before(:all) do
|
221
|
+
@client = Client.new :write_key => WRITE_KEY
|
222
|
+
@queue = @client.instance_variable_get :@queue
|
223
|
+
end
|
224
|
+
|
225
|
+
|
226
|
+
it 'should not convert ids given as fixnums to strings' do
|
227
|
+
[:track, :screen, :page, :group, :identify, :alias].each do |s|
|
228
|
+
@client.send s, :user_id => 1, :group_id => 2, :previous_id => 3, :anonymous_id => 4, :event => "coco barked", :name => "coco"
|
229
|
+
message = @queue.pop(true)
|
230
|
+
check_property.call(message, :userId, 1)
|
231
|
+
check_property.call(message, :groupId, 2)
|
232
|
+
check_property.call(message, :previousId, 3)
|
233
|
+
check_property.call(message, :anonymousId, 4)
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
it 'should send integrations' do
|
238
|
+
[:track, :screen, :page, :group, :identify, :alias].each do |s|
|
239
|
+
@client.send s, :integrations => { All: true, Salesforce: false }, :user_id => 1, :group_id => 2, :previous_id => 3, :anonymous_id => 4, :event => "coco barked", :name => "coco"
|
240
|
+
message = @queue.pop(true)
|
241
|
+
message[:integrations][:All].should be_true
|
242
|
+
message[:integrations][:Salesforce].should be_false
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|