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.
@@ -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,6 @@
1
+ module Segment
2
+ class Analytics
3
+ VERSION = '2.0.1'
4
+ end
5
+ end
6
+
@@ -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