astronomer 2.0.14

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ module Segment
2
+ class Analytics
3
+ module Defaults
4
+ module Request
5
+ HOST = 'api.astronomer.io'
6
+ PORT = 443
7
+ PATH = '/v1/import'
8
+ SSL = true
9
+ HEADERS = { :accept => 'application/json' }
10
+ RETRIES = 4
11
+ BACKOFF = 30.0
12
+ end
13
+
14
+ module Queue
15
+ BATCH_SIZE = 100
16
+ MAX_SIZE = 10000
17
+ end
18
+ end
19
+ end
20
+ end
@@ -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,82 @@
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 app_id and batch of messages to the API.
36
+ #
37
+ # returns - Response of the status and error if it exists
38
+ def post(app_id, 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 app_id, nil
47
+
48
+ if self.class.stub
49
+ status = 200
50
+ error = nil
51
+ logger.debug "stubbed request to #{@path}: app id = #{app_id}, 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
+ rescue Exception => e
59
+ unless (remaining_retries -=1).zero?
60
+ sleep(backoff)
61
+ retry
62
+ end
63
+
64
+ logger.error e.message
65
+ e.backtrace.each { |line| logger.error line }
66
+ status = -1
67
+ error = "Connection error: #{e}"
68
+ end
69
+
70
+ Response.new status, error
71
+ end
72
+
73
+ class << self
74
+ attr_accessor :stub
75
+
76
+ def stub
77
+ @stub || ENV['STUB']
78
+ end
79
+ end
80
+ end
81
+ end
82
+ 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,88 @@
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
+ case datetime
53
+ when Time
54
+ time_in_iso8601 datetime
55
+ when DateTime
56
+ time_in_iso8601 datetime.to_time
57
+ when Date
58
+ date_in_iso8601 datetime
59
+ else
60
+ datetime
61
+ end
62
+ end
63
+
64
+ def time_in_iso8601 time, fraction_digits = 3
65
+ fraction = if fraction_digits > 0
66
+ (".%06i" % time.usec)[0, fraction_digits + 1]
67
+ end
68
+
69
+ "#{time.strftime("%Y-%m-%dT%H:%M:%S")}#{fraction}#{formatted_offset(time, true, 'Z')}"
70
+ end
71
+
72
+ def date_in_iso8601 date
73
+ date.strftime("%F")
74
+ end
75
+
76
+ def formatted_offset time, colon = true, alternate_utc_string = nil
77
+ time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
78
+ end
79
+
80
+ def seconds_to_utc_offset(seconds, colon = true)
81
+ (colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON) % [(seconds < 0 ? '-' : '+'), (seconds.abs / 3600), ((seconds.abs % 3600) / 60)]
82
+ end
83
+
84
+ UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
85
+ UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,5 @@
1
+ module Segment
2
+ class Analytics
3
+ VERSION = '2.0.13'
4
+ end
5
+ end
@@ -0,0 +1,60 @@
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
+ # app_id - String of the project's app_id
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, app_id, options = {})
24
+ symbolize_keys! options
25
+ @queue = queue
26
+ @app_id = app_id
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 @app_id, @batch
46
+
47
+ @lock.synchronize { @batch.clear }
48
+
49
+ @on_error.call res.status, res.error unless res.status == 200
50
+ end
51
+ end
52
+
53
+ # public: Check whether we have outstanding requests.
54
+ #
55
+ def is_requesting?
56
+ @lock.synchronize { !@batch.empty? }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,302 @@
1
+ require 'spec_helper'
2
+
3
+ module Segment
4
+ class Analytics
5
+ describe Client do
6
+ let(:client) { Client.new :write_key => WRITE_KEY }
7
+ let(:queue) { client.instance_variable_get :@queue }
8
+
9
+ describe '#initialize' do
10
+ it 'errors if no write_key is supplied' do
11
+ expect { Client.new }.to raise_error(ArgumentError)
12
+ end
13
+
14
+ it 'does not error if a write_key is supplied' do
15
+ expect do
16
+ Client.new :write_key => WRITE_KEY
17
+ end.to_not raise_error
18
+ end
19
+
20
+ it 'does not error if a write_key is supplied as a string' do
21
+ expect do
22
+ Client.new 'write_key' => WRITE_KEY
23
+ end.to_not raise_error
24
+ end
25
+ end
26
+
27
+ describe '#track' do
28
+ it 'errors without an event' do
29
+ expect { client.track(:user_id => 'user') }.to raise_error(ArgumentError)
30
+ end
31
+
32
+ it 'errors without a user_id' do
33
+ expect { client.track(:event => 'Event') }.to raise_error(ArgumentError)
34
+ end
35
+
36
+ it 'errors if properties is not a hash' do
37
+ expect {
38
+ client.track({
39
+ :user_id => 'user',
40
+ :event => 'Event',
41
+ :properties => [1,2,3]
42
+ })
43
+ }.to raise_error(ArgumentError)
44
+ end
45
+
46
+ it 'uses the timestamp given' do
47
+ time = Time.parse("1990-07-16 13:30:00.123 UTC")
48
+
49
+ client.track({
50
+ :event => 'testing the timestamp',
51
+ :user_id => 'joe',
52
+ :timestamp => time
53
+ })
54
+
55
+ msg = queue.pop
56
+
57
+ expect(Time.parse(msg[:timestamp])).to eq(time)
58
+ end
59
+
60
+ it 'does not error with the required options' do
61
+ expect do
62
+ client.track Queued::TRACK
63
+ queue.pop
64
+ end.to_not raise_error
65
+ end
66
+
67
+ it 'does not error when given string keys' do
68
+ expect do
69
+ client.track Utils.stringify_keys(Queued::TRACK)
70
+ queue.pop
71
+ end.to_not raise_error
72
+ end
73
+
74
+ it 'converts time and date traits into iso8601 format' do
75
+ client.track({
76
+ :user_id => 'user',
77
+ :event => 'Event',
78
+ :properties => {
79
+ :time => Time.utc(2013),
80
+ :time_with_zone => Time.zone.parse('2013-01-01'),
81
+ :date_time => DateTime.new(2013,1,1),
82
+ :date => Date.new(2013,1,1),
83
+ :nottime => 'x'
84
+ }
85
+ })
86
+ message = queue.pop
87
+
88
+ expect(message[:properties][:time]).to eq('2013-01-01T00:00:00.000Z')
89
+ expect(message[:properties][:time_with_zone]).to eq('2013-01-01T00:00:00.000Z')
90
+ expect(message[:properties][:date_time]).to eq('2013-01-01T00:00:00.000Z')
91
+ expect(message[:properties][:date]).to eq('2013-01-01')
92
+ expect(message[:properties][:nottime]).to eq('x')
93
+ end
94
+ end
95
+
96
+
97
+ describe '#identify' do
98
+ it 'errors without any user id' do
99
+ expect { client.identify({}) }.to raise_error(ArgumentError)
100
+ end
101
+
102
+ it 'does not error with the required options' do
103
+ expect do
104
+ client.identify Queued::IDENTIFY
105
+ queue.pop
106
+ end.to_not raise_error
107
+ end
108
+
109
+ it 'does not error with the required options as strings' do
110
+ expect do
111
+ client.identify Utils.stringify_keys(Queued::IDENTIFY)
112
+ queue.pop
113
+ end.to_not raise_error
114
+ end
115
+
116
+ it 'converts time and date traits into iso8601 format' do
117
+ client.identify({
118
+ :user_id => 'user',
119
+ :traits => {
120
+ :time => Time.utc(2013),
121
+ :time_with_zone => Time.zone.parse('2013-01-01'),
122
+ :date_time => DateTime.new(2013,1,1),
123
+ :date => Date.new(2013,1,1),
124
+ :nottime => 'x'
125
+ }
126
+ })
127
+
128
+ message = queue.pop
129
+
130
+ expect(message[:traits][:time]).to eq('2013-01-01T00:00:00.000Z')
131
+ expect(message[:traits][:time_with_zone]).to eq('2013-01-01T00:00:00.000Z')
132
+ expect(message[:traits][:date_time]).to eq('2013-01-01T00:00:00.000Z')
133
+ expect(message[:traits][:date]).to eq('2013-01-01')
134
+ expect(message[:traits][:nottime]).to eq('x')
135
+ end
136
+ end
137
+
138
+ describe '#alias' do
139
+ it 'errors without from' do
140
+ expect { client.alias :user_id => 1234 }.to raise_error(ArgumentError)
141
+ end
142
+
143
+ it 'errors without to' do
144
+ expect { client.alias :previous_id => 1234 }.to raise_error(ArgumentError)
145
+ end
146
+
147
+ it 'does not error with the required options' do
148
+ expect { client.alias ALIAS }.to_not raise_error
149
+ end
150
+
151
+ it 'does not error with the required options as strings' do
152
+ expect do
153
+ client.alias Utils.stringify_keys(ALIAS)
154
+ end.to_not raise_error
155
+ end
156
+ end
157
+
158
+ describe '#group' do
159
+ after do
160
+ client.flush
161
+ end
162
+
163
+ it 'errors without group_id' do
164
+ expect { client.group :user_id => 'foo' }.to raise_error(ArgumentError)
165
+ end
166
+
167
+ it 'errors without user_id' do
168
+ expect { client.group :group_id => 'foo' }.to raise_error(ArgumentError)
169
+ end
170
+
171
+ it 'does not error with the required options' do
172
+ client.group Queued::GROUP
173
+ end
174
+
175
+ it 'does not error with the required options as strings' do
176
+ client.group Utils.stringify_keys(Queued::GROUP)
177
+ end
178
+
179
+ it 'converts time and date traits into iso8601 format' do
180
+ client.identify({
181
+ :user_id => 'user',
182
+ :group_id => 'group',
183
+ :traits => {
184
+ :time => Time.utc(2013),
185
+ :time_with_zone => Time.zone.parse('2013-01-01'),
186
+ :date_time => DateTime.new(2013,1,1),
187
+ :date => Date.new(2013,1,1),
188
+ :nottime => 'x'
189
+ }
190
+ })
191
+
192
+ message = queue.pop
193
+
194
+ expect(message[:traits][:time]).to eq('2013-01-01T00:00:00.000Z')
195
+ expect(message[:traits][:time_with_zone]).to eq('2013-01-01T00:00:00.000Z')
196
+ expect(message[:traits][:date_time]).to eq('2013-01-01T00:00:00.000Z')
197
+ expect(message[:traits][:date]).to eq('2013-01-01')
198
+ expect(message[:traits][:nottime]).to eq('x')
199
+ end
200
+ end
201
+
202
+ describe '#page' do
203
+ it 'errors without user_id' do
204
+ expect { client.page :name => 'foo' }.to raise_error(ArgumentError)
205
+ end
206
+
207
+ it 'does not error with the required options' do
208
+ expect { client.page Queued::PAGE }.to_not raise_error
209
+ end
210
+
211
+ it 'does not error with the required options as strings' do
212
+ expect do
213
+ client.page Utils.stringify_keys(Queued::PAGE)
214
+ end.to_not raise_error
215
+ end
216
+ end
217
+
218
+ describe '#screen' do
219
+ it 'errors without user_id' do
220
+ expect { client.screen :name => 'foo' }.to raise_error(ArgumentError)
221
+ end
222
+
223
+ it 'does not error with the required options' do
224
+ expect { client.screen Queued::SCREEN }.to_not raise_error
225
+ end
226
+
227
+ it 'does not error with the required options as strings' do
228
+ expect do
229
+ client.screen Utils.stringify_keys(Queued::SCREEN)
230
+ end.to_not raise_error
231
+ end
232
+ end
233
+
234
+ describe '#flush' do
235
+ it 'waits for the queue to finish on a flush' do
236
+ client.identify Queued::IDENTIFY
237
+ client.track Queued::TRACK
238
+ client.flush
239
+
240
+ expect(client.queued_messages).to eq(0)
241
+ end
242
+
243
+ it 'completes when the process forks' do
244
+ client.identify Queued::IDENTIFY
245
+
246
+ Process.fork do
247
+ client.track Queued::TRACK
248
+ client.flush
249
+ expect(client.queued_messages).to eq(0)
250
+ end
251
+
252
+ Process.wait
253
+ end unless defined? JRUBY_VERSION
254
+ end
255
+
256
+ context 'common' do
257
+ check_property = proc { |msg, k, v| msg[k] && msg[k] == v }
258
+
259
+ let(:data) { { :user_id => 1, :group_id => 2, :previous_id => 3, :anonymous_id => 4, :event => "coco barked", :name => "coco" } }
260
+
261
+ it 'does not convert ids given as fixnums to strings' do
262
+ [:track, :screen, :page, :identify].each do |s|
263
+ client.send(s, data)
264
+ message = queue.pop(true)
265
+
266
+ expect(check_property.call(message, :userId, 1)).to eq(true)
267
+ expect(check_property.call(message, :anonymousId, 4)).to eq(true)
268
+ end
269
+ end
270
+
271
+ context 'group' do
272
+ it 'does not convert ids given as fixnums to strings' do
273
+ client.group(data)
274
+ message = queue.pop(true)
275
+
276
+ expect(check_property.call(message, :userId, 1)).to eq(true)
277
+ expect(check_property.call(message, :groupId, 2)).to eq(true)
278
+ end
279
+ end
280
+
281
+ context 'alias' do
282
+ it 'does not convert ids given as fixnums to strings' do
283
+ client.alias(data)
284
+ message = queue.pop(true)
285
+
286
+ expect(check_property.call(message, :userId, 1)).to eq(true)
287
+ expect(check_property.call(message, :previousId, 3)).to eq(true)
288
+ end
289
+ end
290
+
291
+ it 'sends integrations' do
292
+ [:track, :screen, :page, :group, :identify, :alias].each do |s|
293
+ 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"
294
+ message = queue.pop(true)
295
+ expect(message[:integrations][:All]).to eq(true)
296
+ expect(message[:integrations][:Salesforce]).to eq(false)
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end