clearbit 0.2.7 → 0.3.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,49 @@
1
+ require 'clearbit/analytics/defaults'
2
+
3
+ module Clearbit
4
+ class Analytics
5
+ class BackoffPolicy
6
+ include Clearbit::Analytics::Defaults::BackoffPolicy
7
+
8
+ # @param [Hash] opts
9
+ # @option opts [Numeric] :min_timeout_ms The minimum backoff timeout
10
+ # @option opts [Numeric] :max_timeout_ms The maximum backoff timeout
11
+ # @option opts [Numeric] :multiplier The value to multiply the current
12
+ # interval with for each retry attempt
13
+ # @option opts [Numeric] :randomization_factor The randomization factor
14
+ # to use to create a range around the retry interval
15
+ def initialize(opts = {})
16
+ @min_timeout_ms = opts[:min_timeout_ms] || MIN_TIMEOUT_MS
17
+ @max_timeout_ms = opts[:max_timeout_ms] || MAX_TIMEOUT_MS
18
+ @multiplier = opts[:multiplier] || MULTIPLIER
19
+ @randomization_factor = opts[:randomization_factor] || RANDOMIZATION_FACTOR
20
+
21
+ @attempts = 0
22
+ end
23
+
24
+ # @return [Numeric] the next backoff interval, in milliseconds.
25
+ def next_interval
26
+ interval = @min_timeout_ms * (@multiplier**@attempts)
27
+ interval = add_jitter(interval, @randomization_factor)
28
+
29
+ @attempts += 1
30
+
31
+ [interval, @max_timeout_ms].min
32
+ end
33
+
34
+ private
35
+
36
+ def add_jitter(base, randomization_factor)
37
+ random_number = rand
38
+ max_deviation = base * randomization_factor
39
+ deviation = random_number * max_deviation
40
+
41
+ if random_number < 0.5
42
+ base - deviation
43
+ else
44
+ base + deviation
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,189 @@
1
+ require 'thread'
2
+ require 'time'
3
+
4
+ require 'clearbit/analytics/defaults'
5
+ require 'clearbit/analytics/logging'
6
+ require 'clearbit/analytics/utils'
7
+ require 'clearbit/analytics/worker'
8
+
9
+ module Clearbit
10
+ class Analytics
11
+ class Client
12
+ include Clearbit::Analytics::Utils
13
+ include Clearbit::Analytics::Logging
14
+
15
+ # @param [Hash] opts
16
+ # @option opts [String] :write_key Your project's write_key
17
+ # @option opts [FixNum] :max_queue_size Maximum number of calls to be
18
+ # remain queued.
19
+ # @option opts [Proc] :on_error Handles error calls from the API.
20
+ def initialize(opts = {})
21
+ symbolize_keys!(opts)
22
+
23
+ @queue = Queue.new
24
+ @write_key = opts[:write_key]
25
+ @max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
26
+ @worker_mutex = Mutex.new
27
+ @worker = Worker.new(@queue, @write_key, opts)
28
+
29
+ check_write_key!
30
+
31
+ at_exit { @worker_thread && @worker_thread[:should_exit] = true }
32
+ end
33
+
34
+ # Synchronously waits until the worker has flushed the queue.
35
+ #
36
+ # Use only for scripts which are not long-running, and will specifically
37
+ # exit
38
+ def flush
39
+ while !@queue.empty? || @worker.is_requesting?
40
+ ensure_worker_running
41
+ sleep(0.1)
42
+ end
43
+ end
44
+
45
+ # @!macro common_attrs
46
+ # @option attrs [String] :anonymous_id ID for a user when you don't know
47
+ # who they are yet. (optional but you must provide either an
48
+ # `anonymous_id` or `user_id`)
49
+ # @option attrs [Hash] :context ({})
50
+ # @option attrs [Hash] :integrations What integrations this event
51
+ # goes to (optional)
52
+ # @option attrs [String] :message_id ID that uniquely
53
+ # identifies a message across the API. (optional)
54
+ # @option attrs [Time] :timestamp When the event occurred (optional)
55
+ # @option attrs [String] :user_id The ID for this user in your database
56
+ # (optional but you must provide either an `anonymous_id` or `user_id`)
57
+ # @option attrs [Hash] :options Options such as user traits (optional)
58
+
59
+ # Tracks an event
60
+ #
61
+ # @see https://segment.com/docs/sources/server/ruby/#track
62
+ #
63
+ # @param [Hash] attrs
64
+ #
65
+ # @option attrs [String] :event Event name
66
+ # @option attrs [Hash] :properties Event properties (optional)
67
+ # @macro common_attrs
68
+ def track(attrs)
69
+ symbolize_keys! attrs
70
+ enqueue(FieldParser.parse_for_track(attrs))
71
+ end
72
+
73
+ # Identifies a user
74
+ #
75
+ # @see https://segment.com/docs/sources/server/ruby/#identify
76
+ #
77
+ # @param [Hash] attrs
78
+ #
79
+ # @option attrs [Hash] :traits User traits (optional)
80
+ # @macro common_attrs
81
+ def identify(attrs)
82
+ symbolize_keys! attrs
83
+ enqueue(FieldParser.parse_for_identify(attrs))
84
+ end
85
+
86
+ # Aliases a user from one id to another
87
+ #
88
+ # @see https://segment.com/docs/sources/server/ruby/#alias
89
+ #
90
+ # @param [Hash] attrs
91
+ #
92
+ # @option attrs [String] :previous_id The ID to alias from
93
+ # @macro common_attrs
94
+ def alias(attrs)
95
+ symbolize_keys! attrs
96
+ enqueue(FieldParser.parse_for_alias(attrs))
97
+ end
98
+
99
+ # Associates a user identity with a group.
100
+ #
101
+ # @see https://segment.com/docs/sources/server/ruby/#group
102
+ #
103
+ # @param [Hash] attrs
104
+ #
105
+ # @option attrs [String] :group_id The ID of the group
106
+ # @option attrs [Hash] :traits User traits (optional)
107
+ # @macro common_attrs
108
+ def group(attrs)
109
+ symbolize_keys! attrs
110
+ enqueue(FieldParser.parse_for_group(attrs))
111
+ end
112
+
113
+ # Records a page view
114
+ #
115
+ # @see https://segment.com/docs/sources/server/ruby/#page
116
+ #
117
+ # @param [Hash] attrs
118
+ #
119
+ # @option attrs [String] :name Name of the page
120
+ # @option attrs [Hash] :properties Page properties (optional)
121
+ # @macro common_attrs
122
+ def page(attrs)
123
+ symbolize_keys! attrs
124
+ enqueue(FieldParser.parse_for_page(attrs))
125
+ end
126
+
127
+ # Records a screen view (for a mobile app)
128
+ #
129
+ # @param [Hash] attrs
130
+ #
131
+ # @option attrs [String] :name Name of the screen
132
+ # @option attrs [Hash] :properties Screen properties (optional)
133
+ # @option attrs [String] :category The screen category (optional)
134
+ # @macro common_attrs
135
+ def screen(attrs)
136
+ symbolize_keys! attrs
137
+ enqueue(FieldParser.parse_for_screen(attrs))
138
+ end
139
+
140
+ # @return [Fixnum] number of messages in the queue
141
+ def queued_messages
142
+ @queue.length
143
+ end
144
+
145
+ private
146
+
147
+ # private: Enqueues the action.
148
+ #
149
+ # returns Boolean of whether the item was added to the queue.
150
+ def enqueue(action)
151
+ # add our request id for tracing purposes
152
+ action[:messageId] ||= uid
153
+
154
+ if @queue.length < @max_queue_size
155
+ @queue << action
156
+ ensure_worker_running
157
+
158
+ true
159
+ else
160
+ logger.warn(
161
+ 'Queue is full, dropping events. The :max_queue_size ' \
162
+ 'configuration parameter can be increased to prevent this from ' \
163
+ 'happening.'
164
+ )
165
+ false
166
+ end
167
+ end
168
+
169
+ # private: Checks that the write_key is properly initialized
170
+ def check_write_key!
171
+ raise ArgumentError, 'Write key must be initialized' if @write_key.nil?
172
+ end
173
+
174
+ def ensure_worker_running
175
+ return if worker_running?
176
+ @worker_mutex.synchronize do
177
+ return if worker_running?
178
+ @worker_thread = Thread.new do
179
+ @worker.run
180
+ end
181
+ end
182
+ end
183
+
184
+ def worker_running?
185
+ @worker_thread && @worker_thread.alive?
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,36 @@
1
+ module Clearbit
2
+ class Analytics
3
+ module Defaults
4
+ module Request
5
+ HOST = 'x.clearbit.com'
6
+ PORT = 443
7
+ PATH = '/v1/import'
8
+ SSL = true
9
+ HEADERS = { 'Accept' => 'application/json',
10
+ 'Content-Type' => 'application/json',
11
+ 'User-Agent' => "clearbit-ruby/#{Clearbit::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,192 @@
1
+ module Clearbit
2
+ class Analytics
3
+ # Handles parsing fields according to the Segment Spec
4
+ #
5
+ # @see https://segment.com/docs/spec/
6
+ class FieldParser
7
+ class << self
8
+ include Clearbit::Analytics::Utils
9
+
10
+ # In addition to the common fields, track accepts:
11
+ #
12
+ # - "event"
13
+ # - "properties"
14
+ def parse_for_track(fields)
15
+ common = parse_common_fields(fields)
16
+
17
+ event = fields[:event]
18
+ properties = fields[:properties] || {}
19
+
20
+ check_presence!(event, 'event')
21
+ check_is_hash!(properties, 'properties')
22
+
23
+ isoify_dates! properties
24
+
25
+ common.merge({
26
+ :type => 'track',
27
+ :event => event.to_s,
28
+ :properties => properties
29
+ })
30
+ end
31
+
32
+ # In addition to the common fields, identify accepts:
33
+ #
34
+ # - "traits"
35
+ def parse_for_identify(fields)
36
+ common = parse_common_fields(fields)
37
+
38
+ traits = fields[:traits] || {}
39
+ check_is_hash!(traits, 'traits')
40
+ isoify_dates! traits
41
+
42
+ common.merge({
43
+ :type => 'identify',
44
+ :traits => traits
45
+ })
46
+ end
47
+
48
+ # In addition to the common fields, alias accepts:
49
+ #
50
+ # - "previous_id"
51
+ def parse_for_alias(fields)
52
+ common = parse_common_fields(fields)
53
+
54
+ previous_id = fields[:previous_id]
55
+ check_presence!(previous_id, 'previous_id')
56
+
57
+ common.merge({
58
+ :type => 'alias',
59
+ :previousId => previous_id
60
+ })
61
+ end
62
+
63
+ # In addition to the common fields, group accepts:
64
+ #
65
+ # - "group_id"
66
+ # - "traits"
67
+ def parse_for_group(fields)
68
+ common = parse_common_fields(fields)
69
+
70
+ group_id = fields[:group_id]
71
+ traits = fields[:traits] || {}
72
+
73
+ check_presence!(group_id, 'group_id')
74
+ check_is_hash!(traits, 'traits')
75
+
76
+ isoify_dates! traits
77
+
78
+ common.merge({
79
+ :type => 'group',
80
+ :groupId => group_id,
81
+ :traits => traits
82
+ })
83
+ end
84
+
85
+ # In addition to the common fields, page accepts:
86
+ #
87
+ # - "name"
88
+ # - "properties"
89
+ def parse_for_page(fields)
90
+ common = parse_common_fields(fields)
91
+
92
+ name = fields[:name] || ''
93
+ properties = fields[:properties] || {}
94
+
95
+ check_is_hash!(properties, 'properties')
96
+
97
+ isoify_dates! properties
98
+
99
+ common.merge({
100
+ :type => 'page',
101
+ :name => name.to_s,
102
+ :properties => properties
103
+ })
104
+ end
105
+
106
+ # In addition to the common fields, screen accepts:
107
+ #
108
+ # - "name"
109
+ # - "properties"
110
+ # - "category" (Not in spec, retained for backward compatibility"
111
+ def parse_for_screen(fields)
112
+ common = parse_common_fields(fields)
113
+
114
+ name = fields[:name]
115
+ properties = fields[:properties] || {}
116
+ category = fields[:category]
117
+
118
+ check_presence!(name, 'name')
119
+ check_is_hash!(properties, 'properties')
120
+
121
+ isoify_dates! properties
122
+
123
+ parsed = common.merge({
124
+ :type => 'screen',
125
+ :name => name,
126
+ :properties => properties
127
+ })
128
+
129
+ parsed[:category] = category if category
130
+
131
+ parsed
132
+ end
133
+
134
+ private
135
+
136
+ def parse_common_fields(fields)
137
+ timestamp = fields[:timestamp] || Time.new
138
+ message_id = fields[:message_id].to_s if fields[:message_id]
139
+ context = fields[:context] || {}
140
+
141
+ check_user_id! fields
142
+ check_timestamp! timestamp
143
+
144
+ add_context! context
145
+
146
+ parsed = {
147
+ :context => context,
148
+ :messageId => message_id,
149
+ :timestamp => datetime_in_iso8601(timestamp)
150
+ }
151
+
152
+ parsed[:userId] = fields[:user_id] if fields[:user_id]
153
+ parsed[:anonymousId] = fields[:anonymous_id] if fields[:anonymous_id]
154
+ parsed[:integrations] = fields[:integrations] if fields[:integrations]
155
+
156
+ # Not in spec, retained for backward compatibility
157
+ parsed[:options] = fields[:options] if fields[:options]
158
+
159
+ parsed
160
+ end
161
+
162
+ def check_user_id!(fields)
163
+ unless fields[:user_id] || fields[:anonymous_id]
164
+ raise ArgumentError, 'Must supply either user_id or anonymous_id'
165
+ end
166
+ end
167
+
168
+ def check_timestamp!(timestamp)
169
+ raise ArgumentError, 'Timestamp must be a Time' unless timestamp.is_a? Time
170
+ end
171
+
172
+ def add_context!(context)
173
+ context[:library] = { :name => 'clearbit-ruby', :version => Clearbit::VERSION.to_s }
174
+ end
175
+
176
+ # private: Ensures that a string is non-empty
177
+ #
178
+ # obj - String|Number that must be non-blank
179
+ # name - Name of the validated value
180
+ def check_presence!(obj, name)
181
+ if obj.nil? || (obj.is_a?(String) && obj.empty?)
182
+ raise ArgumentError, "#{name} must be given"
183
+ end
184
+ end
185
+
186
+ def check_is_hash!(obj, name)
187
+ raise ArgumentError, "#{name} must be a Hash" unless obj.is_a? Hash
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end