events-sdk-ruby 0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dcf2a40935376b273b429ab73d49f299cfa056d6b85d0de15695e4cc11422707
4
+ data.tar.gz: 41c229206e54bdf563bbbc21408031d3fb61b1e7270919528266a602ca89ebe8
5
+ SHA512:
6
+ metadata.gz: a1340235d5dd7c589558bb1d51c68d56a74e7aa5a08375acbeb8367d2d83772f2cb7764c243897760811ca6e82d050faf479d0973aa3d510299adbf60319ac08
7
+ data.tar.gz: d9904ab645177fbac8dbf2581651b37f1809225802ccdcc97e4acce613246202de7e82cb2172deb4860889e460ea229f5660b56f1d23e1f4828aa33ca1d36eef
data/bin/analytics ADDED
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'hightouch/analytics'
4
+ require 'rubygems'
5
+ require 'commander/import'
6
+ require 'time'
7
+ require 'json'
8
+
9
+ program :name, 'simulator.rb'
10
+ program :version, '0.0.1'
11
+ program :description, 'scripting simulator'
12
+
13
+ def json_hash(str)
14
+ if str
15
+ return JSON.parse(str)
16
+ end
17
+ end
18
+
19
+ # analytics -method=<method> -hightouch-write-key=<hightouchWriteKey> [options]
20
+
21
+ default_command :send
22
+
23
+ command :send do |c|
24
+ c.description = 'send a hightouch message'
25
+
26
+ c.option '--writeKey=<writeKey>', String, 'the Hightouch writeKey'
27
+ c.option '--type=<type>', String, 'The Hightouch message type'
28
+
29
+ c.option '--userId=<userId>', String, 'the user id to send the event as'
30
+ c.option '--anonymousId=<anonymousId>', String, 'the anonymous user id to send the event as'
31
+ c.option '--context=<context>', 'additional context for the event (JSON-encoded)'
32
+ c.option '--integrations=<integrations>', 'additional integrations for the event (JSON-encoded)'
33
+
34
+ c.option '--event=<event>', String, 'the event name to send with the event'
35
+ c.option '--properties=<properties>', 'the event properties to send (JSON-encoded)'
36
+
37
+ c.option '--name=<name>', 'name of the screen or page to send with the message'
38
+
39
+ c.option '--traits=<traits>', 'the identify/group traits to send (JSON-encoded)'
40
+
41
+ c.option '--groupId=<groupId>', String, 'the group id'
42
+ c.option '--previousId=<previousId>', String, 'the previous id'
43
+
44
+ c.action do |args, options|
45
+ Analytics = Hightouch::Analytics.new({
46
+ :write_key => options.writeKey,
47
+ :on_error => Proc.new { |status, msg| print msg }
48
+ })
49
+
50
+ case options.type
51
+ when "track"
52
+ Analytics.track({
53
+ :user_id => options.userId,
54
+ :event => options.event,
55
+ :anonymous_id => options.anonymousId,
56
+ :properties => json_hash(options.properties),
57
+ :context => json_hash(options.context),
58
+ :integrations => json_hash(options.integrations)
59
+ })
60
+ when "page"
61
+ Analytics.page({
62
+ :user_id => options.userId,
63
+ :anonymous_id => options.anonymousId,
64
+ :name => options.name,
65
+ :properties => json_hash(options.properties),
66
+ :context => json_hash(options.context),
67
+ :integrations => json_hash(options.integrations)
68
+ })
69
+ when "screen"
70
+ Analytics.screen({
71
+ :user_id => options.userId,
72
+ :anonymous_id => options.anonymousId,
73
+ :name => options.name,
74
+ :properties => json_hash(options.properties),
75
+ :context => json_hash(options.context),
76
+ :integrations => json_hash(options.integrations)
77
+ })
78
+ when "identify"
79
+ Analytics.identify({
80
+ :user_id => options.userId,
81
+ :anonymous_id => options.anonymousId,
82
+ :traits => json_hash(options.traits),
83
+ :context => json_hash(options.context),
84
+ :integrations => json_hash(options.integrations)
85
+ })
86
+ when "group"
87
+ Analytics.group({
88
+ :user_id => options.userId,
89
+ :anonymous_id => options.anonymousId,
90
+ :group_id => options.groupId,
91
+ :traits => json_hash(options.traits),
92
+ :context => json_hash(options.context),
93
+ :integrations => json_hash(options.integrations)
94
+ })
95
+ when "alias"
96
+ Analytics.alias({
97
+ :previous_id => options.previousId,
98
+ :user_id => options.userId,
99
+ :anonymous_id => options.anonymousId,
100
+ :context => json_hash(options.context),
101
+ :integrations => json_hash(options.integrations)
102
+ })
103
+ else
104
+ raise "Invalid Message Type #{options.type}"
105
+ end
106
+ Analytics.flush
107
+ end
108
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hightouch/analytics/defaults'
4
+
5
+ module Hightouch
6
+ class Analytics
7
+ class BackoffPolicy
8
+ include Hightouch::Analytics::Defaults::BackoffPolicy
9
+
10
+ # @param [Hash] opts
11
+ # @option opts [Numeric] :min_timeout_ms The minimum backoff timeout
12
+ # @option opts [Numeric] :max_timeout_ms The maximum backoff timeout
13
+ # @option opts [Numeric] :multiplier The value to multiply the current
14
+ # interval with for each retry attempt
15
+ # @option opts [Numeric] :randomization_factor The randomization factor
16
+ # to use to create a range around the retry interval
17
+ def initialize(opts = {})
18
+ @min_timeout_ms = opts[:min_timeout_ms] || MIN_TIMEOUT_MS
19
+ @max_timeout_ms = opts[:max_timeout_ms] || MAX_TIMEOUT_MS
20
+ @multiplier = opts[:multiplier] || MULTIPLIER
21
+ @randomization_factor = opts[:randomization_factor] || RANDOMIZATION_FACTOR
22
+
23
+ @attempts = 0
24
+ end
25
+
26
+ # @return [Numeric] the next backoff interval, in milliseconds.
27
+ def next_interval
28
+ interval = @min_timeout_ms * (@multiplier**@attempts)
29
+ interval = add_jitter(interval, @randomization_factor)
30
+
31
+ @attempts += 1
32
+
33
+ [interval, @max_timeout_ms].min
34
+ end
35
+
36
+ private
37
+
38
+ def add_jitter(base, randomization_factor)
39
+ random_number = rand
40
+ max_deviation = base * randomization_factor
41
+ deviation = random_number * max_deviation
42
+
43
+ if random_number < 0.5
44
+ base - deviation
45
+ else
46
+ base + deviation
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thread'
4
+ require 'time'
5
+
6
+ require 'hightouch/analytics/defaults'
7
+ require 'hightouch/analytics/logging'
8
+ require 'hightouch/analytics/utils'
9
+ require 'hightouch/analytics/worker'
10
+
11
+ module Hightouch
12
+ class Analytics
13
+ class Client
14
+ include Hightouch::Analytics::Utils
15
+ include Hightouch::Analytics::Logging
16
+
17
+ # @param [Hash] opts
18
+ # @option opts [String] :write_key Your project's write_key
19
+ # @option opts [FixNum] :max_queue_size Maximum number of calls to be
20
+ # remain queued.
21
+ # @option opts [Proc] :on_error Handles error calls from the API.
22
+ def initialize(opts = {})
23
+ symbolize_keys!(opts)
24
+
25
+ @queue = Queue.new
26
+ @test = opts[:test]
27
+ @write_key = opts[:write_key]
28
+ @max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
29
+ @worker_mutex = Mutex.new
30
+ @worker = Worker.new(@queue, @write_key, opts)
31
+ @worker_thread = nil
32
+
33
+ check_write_key!
34
+
35
+ at_exit { @worker_thread && @worker_thread[:should_exit] = true }
36
+ end
37
+
38
+ # Synchronously waits until the worker has flushed the queue.
39
+ #
40
+ # Use only for scripts which are not long-running, and will specifically
41
+ # exit
42
+ def flush
43
+ while !@queue.empty? || @worker.is_requesting?
44
+ ensure_worker_running
45
+ sleep(0.1)
46
+ end
47
+ end
48
+
49
+ # @!macro common_attrs
50
+ # @option attrs [String] :anonymous_id ID for a user when you don't know
51
+ # who they are yet. (optional but you must provide either an
52
+ # `anonymous_id` or `user_id`)
53
+ # @option attrs [Hash] :context ({})
54
+ # @option attrs [Hash] :integrations What integrations this event
55
+ # goes to (optional)
56
+ # @option attrs [String] :message_id ID that uniquely
57
+ # identifies a message across the API. (optional)
58
+ # @option attrs [Time] :timestamp When the event occurred (optional)
59
+ # @option attrs [String] :user_id The ID for this user in your database
60
+ # (optional but you must provide either an `anonymous_id` or `user_id`)
61
+ # @option attrs [Hash] :options Options such as user traits (optional)
62
+
63
+ # Tracks an event
64
+ #
65
+ # @see https://hightouch.com/docs/sources/server/ruby/#track
66
+ #
67
+ # @param [Hash] attrs
68
+ #
69
+ # @option attrs [String] :event Event name
70
+ # @option attrs [Hash] :properties Event properties (optional)
71
+ # @macro common_attrs
72
+ def track(attrs)
73
+ symbolize_keys! attrs
74
+ enqueue(FieldParser.parse_for_track(attrs))
75
+ end
76
+
77
+ # Identifies a user
78
+ #
79
+ # @see https://hightouch.com/docs/sources/server/ruby/#identify
80
+ #
81
+ # @param [Hash] attrs
82
+ #
83
+ # @option attrs [Hash] :traits User traits (optional)
84
+ # @macro common_attrs
85
+ def identify(attrs)
86
+ symbolize_keys! attrs
87
+ enqueue(FieldParser.parse_for_identify(attrs))
88
+ end
89
+
90
+ # Aliases a user from one id to another
91
+ #
92
+ # @see https://hightouch.com/docs/sources/server/ruby/#alias
93
+ #
94
+ # @param [Hash] attrs
95
+ #
96
+ # @option attrs [String] :previous_id The ID to alias from
97
+ # @macro common_attrs
98
+ def alias(attrs)
99
+ symbolize_keys! attrs
100
+ enqueue(FieldParser.parse_for_alias(attrs))
101
+ end
102
+
103
+ # Associates a user identity with a group.
104
+ #
105
+ # @see https://hightouch.com/docs/sources/server/ruby/#group
106
+ #
107
+ # @param [Hash] attrs
108
+ #
109
+ # @option attrs [String] :group_id The ID of the group
110
+ # @option attrs [Hash] :traits User traits (optional)
111
+ # @macro common_attrs
112
+ def group(attrs)
113
+ symbolize_keys! attrs
114
+ enqueue(FieldParser.parse_for_group(attrs))
115
+ end
116
+
117
+ # Records a page view
118
+ #
119
+ # @see https://hightouch.com/docs/sources/server/ruby/#page
120
+ #
121
+ # @param [Hash] attrs
122
+ #
123
+ # @option attrs [String] :name Name of the page
124
+ # @option attrs [Hash] :properties Page properties (optional)
125
+ # @macro common_attrs
126
+ def page(attrs)
127
+ symbolize_keys! attrs
128
+ enqueue(FieldParser.parse_for_page(attrs))
129
+ end
130
+
131
+ # Records a screen view (for a mobile app)
132
+ #
133
+ # @param [Hash] attrs
134
+ #
135
+ # @option attrs [String] :name Name of the screen
136
+ # @option attrs [Hash] :properties Screen properties (optional)
137
+ # @option attrs [String] :category The screen category (optional)
138
+ # @macro common_attrs
139
+ def screen(attrs)
140
+ symbolize_keys! attrs
141
+ enqueue(FieldParser.parse_for_screen(attrs))
142
+ end
143
+
144
+ # @return [Fixnum] number of messages in the queue
145
+ def queued_messages
146
+ @queue.length
147
+ end
148
+
149
+ def test_queue
150
+ unless @test
151
+ raise 'Test queue only available when setting :test to true.'
152
+ end
153
+
154
+ @test_queue ||= TestQueue.new
155
+ end
156
+
157
+ private
158
+
159
+ # private: Enqueues the action.
160
+ #
161
+ # returns Boolean of whether the item was added to the queue.
162
+ def enqueue(action)
163
+ # add our request id for tracing purposes
164
+ action[:messageId] ||= uid
165
+
166
+ if @test
167
+ test_queue << action
168
+ return true
169
+ end
170
+
171
+ if @queue.length < @max_queue_size
172
+ @queue << action
173
+ ensure_worker_running
174
+
175
+ true
176
+ else
177
+ logger.warn(
178
+ 'Queue is full, dropping events. The :max_queue_size configuration parameter can be increased to prevent this from happening.'
179
+ )
180
+ false
181
+ end
182
+ end
183
+
184
+ # private: Checks that the write_key is properly initialized
185
+ def check_write_key!
186
+ raise ArgumentError, 'Write key must be initialized' if @write_key.nil?
187
+ end
188
+
189
+ def ensure_worker_running
190
+ return if worker_running?
191
+ @worker_mutex.synchronize do
192
+ return if worker_running?
193
+ @worker_thread = Thread.new do
194
+ @worker.run
195
+ end
196
+ end
197
+ end
198
+
199
+ def worker_running?
200
+ @worker_thread && @worker_thread.alive?
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hightouch
4
+ class Analytics
5
+ module Defaults
6
+ module Request
7
+ HOST = 'events.us-east-1.hightouch.com'
8
+ PORT = 443
9
+ PATH = '/v1/batch'
10
+ SSL = true
11
+ HEADERS = { 'Accept' => 'application/json',
12
+ 'Content-Type' => 'application/json',
13
+ 'User-Agent' => "events-sdk-ruby/#{Analytics::VERSION}" }
14
+ RETRIES = 10
15
+ end
16
+
17
+ module Queue
18
+ MAX_SIZE = 10000
19
+ end
20
+
21
+ module Message
22
+ MAX_BYTES = 32768 # 32Kb
23
+ end
24
+
25
+ module MessageBatch
26
+ MAX_BYTES = 512_000 # 500Kb
27
+ MAX_SIZE = 100
28
+ end
29
+
30
+ module BackoffPolicy
31
+ MIN_TIMEOUT_MS = 100
32
+ MAX_TIMEOUT_MS = 10000
33
+ MULTIPLIER = 1.5
34
+ RANDOMIZATION_FACTOR = 0.5
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hightouch
4
+ class Analytics
5
+ # Handles parsing fields according to the Hightouch Spec
6
+ #
7
+ # @see https://hightouch.com/docs/spec/
8
+ class FieldParser
9
+ class << self
10
+ include Hightouch::Analytics::Utils
11
+
12
+ # In addition to the common fields, track accepts:
13
+ #
14
+ # - "event"
15
+ # - "properties"
16
+ def parse_for_track(fields)
17
+ common = parse_common_fields(fields)
18
+
19
+ event = fields[:event]
20
+ properties = fields[:properties] || {}
21
+
22
+ check_presence!(event, 'event')
23
+ check_is_hash!(properties, 'properties')
24
+
25
+ isoify_dates! properties
26
+
27
+ common.merge({
28
+ :type => 'track',
29
+ :event => event.to_s,
30
+ :properties => properties
31
+ })
32
+ end
33
+
34
+ # In addition to the common fields, identify accepts:
35
+ #
36
+ # - "traits"
37
+ def parse_for_identify(fields)
38
+ common = parse_common_fields(fields)
39
+
40
+ traits = fields[:traits] || {}
41
+ check_is_hash!(traits, 'traits')
42
+ isoify_dates! traits
43
+
44
+ common.merge({
45
+ :type => 'identify',
46
+ :traits => traits
47
+ })
48
+ end
49
+
50
+ # In addition to the common fields, alias accepts:
51
+ #
52
+ # - "previous_id"
53
+ def parse_for_alias(fields)
54
+ common = parse_common_fields(fields)
55
+
56
+ previous_id = fields[:previous_id]
57
+ check_presence!(previous_id, 'previous_id')
58
+
59
+ common.merge({
60
+ :type => 'alias',
61
+ :previousId => previous_id
62
+ })
63
+ end
64
+
65
+ # In addition to the common fields, group accepts:
66
+ #
67
+ # - "group_id"
68
+ # - "traits"
69
+ def parse_for_group(fields)
70
+ common = parse_common_fields(fields)
71
+
72
+ group_id = fields[:group_id]
73
+ traits = fields[:traits] || {}
74
+
75
+ check_presence!(group_id, 'group_id')
76
+ check_is_hash!(traits, 'traits')
77
+
78
+ isoify_dates! traits
79
+
80
+ common.merge({
81
+ :type => 'group',
82
+ :groupId => group_id,
83
+ :traits => traits
84
+ })
85
+ end
86
+
87
+ # In addition to the common fields, page accepts:
88
+ #
89
+ # - "name"
90
+ # - "properties"
91
+ def parse_for_page(fields)
92
+ common = parse_common_fields(fields)
93
+
94
+ name = fields[:name] || ''
95
+ properties = fields[:properties] || {}
96
+
97
+ check_is_hash!(properties, 'properties')
98
+
99
+ isoify_dates! properties
100
+
101
+ common.merge({
102
+ :type => 'page',
103
+ :name => name.to_s,
104
+ :properties => properties
105
+ })
106
+ end
107
+
108
+ # In addition to the common fields, screen accepts:
109
+ #
110
+ # - "name"
111
+ # - "properties"
112
+ # - "category" (Not in spec, retained for backward compatibility"
113
+ def parse_for_screen(fields)
114
+ common = parse_common_fields(fields)
115
+
116
+ name = fields[:name]
117
+ properties = fields[:properties] || {}
118
+ category = fields[:category]
119
+
120
+ check_presence!(name, 'name')
121
+ check_is_hash!(properties, 'properties')
122
+
123
+ isoify_dates! properties
124
+
125
+ parsed = common.merge({
126
+ :type => 'screen',
127
+ :name => name,
128
+ :properties => properties
129
+ })
130
+
131
+ parsed[:category] = category if category
132
+
133
+ parsed
134
+ end
135
+
136
+ private
137
+
138
+ def parse_common_fields(fields)
139
+ timestamp = fields[:timestamp] || Time.new
140
+ message_id = fields[:message_id].to_s if fields[:message_id]
141
+ context = fields[:context] || {}
142
+
143
+ check_user_id! fields
144
+ check_timestamp! timestamp
145
+
146
+ add_context! context
147
+
148
+ parsed = {
149
+ :context => context,
150
+ :messageId => message_id,
151
+ :timestamp => datetime_in_iso8601(timestamp)
152
+ }
153
+
154
+ parsed[:userId] = fields[:user_id] if fields[:user_id]
155
+ parsed[:anonymousId] = fields[:anonymous_id] if fields[:anonymous_id]
156
+ parsed[:integrations] = fields[:integrations] if fields[:integrations]
157
+
158
+ # Not in spec, retained for backward compatibility
159
+ parsed[:options] = fields[:options] if fields[:options]
160
+
161
+ parsed
162
+ end
163
+
164
+ def check_user_id!(fields)
165
+ return unless blank?(fields[:user_id])
166
+ return unless blank?(fields[:anonymous_id])
167
+
168
+ raise ArgumentError, 'Must supply either user_id or anonymous_id'
169
+ end
170
+
171
+ def check_timestamp!(timestamp)
172
+ raise ArgumentError, 'Timestamp must be a Time' unless timestamp.is_a? Time
173
+ end
174
+
175
+ def add_context!(context)
176
+ context[:library] = { :name => 'events-sdk-ruby', :version => Hightouch::Analytics::VERSION.to_s }
177
+ end
178
+
179
+ # private: Ensures that a string is non-empty
180
+ #
181
+ # obj - String|Number that must be non-blank
182
+ # name - Name of the validated value
183
+ def check_presence!(obj, name)
184
+ raise ArgumentError, "#{name} must be given" if blank?(obj)
185
+ end
186
+
187
+ def blank?(obj)
188
+ obj.nil? || (obj.is_a?(String) && obj.empty?)
189
+ end
190
+
191
+ def check_is_hash!(obj, name)
192
+ raise ArgumentError, "#{name} must be a Hash" unless obj.is_a? Hash
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Hightouch
6
+ class Analytics
7
+ # Wraps an existing logger and adds a prefix to all messages
8
+ class PrefixedLogger
9
+ def initialize(logger, prefix)
10
+ @logger = logger
11
+ @prefix = prefix
12
+ end
13
+
14
+ def debug(msg)
15
+ @logger.debug("#{@prefix} #{msg}")
16
+ end
17
+
18
+ def info(msg)
19
+ @logger.info("#{@prefix} #{msg}")
20
+ end
21
+
22
+ def warn(msg)
23
+ @logger.warn("#{@prefix} #{msg}")
24
+ end
25
+
26
+ def error(msg)
27
+ @logger.error("#{@prefix} #{msg}")
28
+ end
29
+ end
30
+
31
+ module Logging
32
+ class << self
33
+ def logger
34
+ return @logger if @logger
35
+
36
+ base_logger = if defined?(Rails)
37
+ Rails.logger
38
+ else
39
+ logger = Logger.new STDOUT
40
+ logger.progname = 'Hightouch::Analytics'
41
+ logger
42
+ end
43
+ @logger = PrefixedLogger.new(base_logger, '[events-sdk-ruby]')
44
+ end
45
+
46
+ attr_writer :logger
47
+ end
48
+
49
+ def self.included(base)
50
+ class << base
51
+ def logger
52
+ Logging.logger
53
+ end
54
+ end
55
+ end
56
+
57
+ def logger
58
+ Logging.logger
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'hightouch/analytics/logging'
5
+
6
+ module Hightouch
7
+ class Analytics
8
+ # A batch of `Message`s to be sent to the API
9
+ class MessageBatch
10
+ class JSONGenerationError < StandardError; end
11
+
12
+ extend Forwardable
13
+ include Hightouch::Analytics::Logging
14
+ include Hightouch::Analytics::Defaults::MessageBatch
15
+
16
+ def initialize(max_message_count)
17
+ @messages = []
18
+ @max_message_count = max_message_count
19
+ @json_size = 0
20
+ end
21
+
22
+ def <<(message)
23
+ begin
24
+ message_json = message.to_json
25
+ rescue StandardError => e
26
+ raise JSONGenerationError, "Serialization error: #{e}"
27
+ end
28
+
29
+ message_json_size = message_json.bytesize
30
+ if message_too_big?(message_json_size)
31
+ logger.error('a message exceeded the maximum allowed size')
32
+ else
33
+ @messages << message
34
+ @json_size += message_json_size + 1 # One byte for the comma
35
+ end
36
+ end
37
+
38
+ def full?
39
+ item_count_exhausted? || size_exhausted?
40
+ end
41
+
42
+ def clear
43
+ @messages.clear
44
+ @json_size = 0
45
+ end
46
+
47
+ def_delegators :@messages, :to_json
48
+ def_delegators :@messages, :empty?
49
+ def_delegators :@messages, :length
50
+
51
+ private
52
+
53
+ def item_count_exhausted?
54
+ @messages.length >= @max_message_count
55
+ end
56
+
57
+ def message_too_big?(message_json_size)
58
+ message_json_size > Defaults::Message::MAX_BYTES
59
+ end
60
+
61
+ # We consider the max size here as just enough to leave room for one more
62
+ # message of the largest size possible. This is a shortcut that allows us
63
+ # to use a native Ruby `Queue` that doesn't allow peeking. The tradeoff
64
+ # here is that we might fit in less messages than possible into a batch.
65
+ #
66
+ # The alternative is to use our own `Queue` implementation that allows
67
+ # peeking, and to consider the next message size when calculating whether
68
+ # the message can be accomodated in this batch.
69
+ def size_exhausted?
70
+ @json_size >= (MAX_BYTES - Defaults::Message::MAX_BYTES)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hightouch
4
+ class Analytics
5
+ class Response
6
+ attr_reader :status, :error
7
+
8
+ # public: Simple class to wrap responses from the API
9
+ #
10
+ #
11
+ def initialize(status = 200, error = nil)
12
+ @status = status
13
+ @error = error
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hightouch
4
+ class Analytics
5
+ class TestQueue
6
+ attr_reader :messages
7
+
8
+ def initialize
9
+ reset!
10
+ end
11
+
12
+ def [](key)
13
+ all[key]
14
+ end
15
+
16
+ def count
17
+ all.count
18
+ end
19
+
20
+ def <<(message)
21
+ all << message
22
+ send(message[:type]) << message
23
+ end
24
+
25
+ def alias
26
+ messages[:alias] ||= []
27
+ end
28
+
29
+ def all
30
+ messages[:all] ||= []
31
+ end
32
+
33
+ def group
34
+ messages[:group] ||= []
35
+ end
36
+
37
+ def identify
38
+ messages[:identify] ||= []
39
+ end
40
+
41
+ def page
42
+ messages[:page] ||= []
43
+ end
44
+
45
+ def screen
46
+ messages[:screen] ||= []
47
+ end
48
+
49
+ def track
50
+ messages[:track] ||= []
51
+ end
52
+
53
+ def reset!
54
+ @messages = {}
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hightouch/analytics/defaults'
4
+ require 'hightouch/analytics/utils'
5
+ require 'hightouch/analytics/response'
6
+ require 'hightouch/analytics/logging'
7
+ require 'hightouch/analytics/backoff_policy'
8
+ require 'net/http'
9
+ require 'net/https'
10
+ require 'json'
11
+
12
+ module Hightouch
13
+ class Analytics
14
+ class Transport
15
+ include Hightouch::Analytics::Defaults::Request
16
+ include Hightouch::Analytics::Utils
17
+ include Hightouch::Analytics::Logging
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] || Hightouch::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
+ # Sends a batch of messages to the API
38
+ #
39
+ # @return [Response] API response
40
+ def send(write_key, batch)
41
+ logger.debug("Sending request for #{batch.length} items")
42
+
43
+ last_response, exception = retry_with_backoff(@retries) do
44
+ status_code, body = send_request(write_key, batch)
45
+ error = JSON.parse(body)['error']
46
+ should_retry = should_retry_request?(status_code, body)
47
+ logger.debug("Response status code: #{status_code}")
48
+ logger.debug("Response error: #{error}") if error
49
+
50
+ [Response.new(status_code, error), should_retry]
51
+ end
52
+
53
+ if exception
54
+ logger.error(exception.message)
55
+ exception.backtrace.each { |line| logger.error(line) }
56
+ Response.new(-1, exception.to_s)
57
+ else
58
+ last_response
59
+ end
60
+ end
61
+
62
+ # Closes a persistent connection if it exists
63
+ def shutdown
64
+ @http.finish if @http.started?
65
+ end
66
+
67
+ private
68
+
69
+ def should_retry_request?(status_code, body)
70
+ if status_code >= 500
71
+ true # Server error
72
+ elsif status_code == 429
73
+ true # Rate limited
74
+ elsif status_code >= 400
75
+ logger.error(body)
76
+ false # Client error. Do not retry, but log
77
+ else
78
+ false
79
+ end
80
+ end
81
+
82
+ # Takes a block that returns [result, should_retry].
83
+ #
84
+ # Retries upto `retries_remaining` times, if `should_retry` is false or
85
+ # an exception is raised. `@backoff_policy` is used to determine the
86
+ # duration to sleep between attempts
87
+ #
88
+ # Returns [last_result, raised_exception]
89
+ def retry_with_backoff(retries_remaining, &block)
90
+ result, caught_exception = nil
91
+ should_retry = false
92
+
93
+ begin
94
+ result, should_retry = yield
95
+ return [result, nil] unless should_retry
96
+ rescue StandardError => e
97
+ should_retry = true
98
+ caught_exception = e
99
+ end
100
+
101
+ if should_retry && (retries_remaining > 1)
102
+ logger.debug("Retrying request, #{retries_remaining} retries left")
103
+ sleep(@backoff_policy.next_interval.to_f / 1000)
104
+ retry_with_backoff(retries_remaining - 1, &block)
105
+ else
106
+ [result, caught_exception]
107
+ end
108
+ end
109
+
110
+ # Sends a request for the batch, returns [status_code, body]
111
+ def send_request(write_key, batch)
112
+ payload = JSON.generate(
113
+ :sentAt => datetime_in_iso8601(Time.now),
114
+ :batch => batch
115
+ )
116
+ request = Net::HTTP::Post.new(@path, @headers)
117
+ request.basic_auth(write_key, nil)
118
+
119
+ if self.class.stub
120
+ logger.debug "stubbed request to #{@path}: " \
121
+ "write key = #{write_key}, batch = #{JSON.generate(batch)}"
122
+
123
+ [200, '{}']
124
+ else
125
+ @http.start unless @http.started? # Maintain a persistent connection
126
+ response = @http.request(request, payload)
127
+ [response.code.to_i, response.body]
128
+ end
129
+ end
130
+
131
+ class << self
132
+ attr_writer :stub
133
+
134
+ def stub
135
+ @stub || ENV['STUB']
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Hightouch
6
+ class Analytics
7
+ module Utils
8
+ extend self
9
+
10
+ # public: Return a new hash with keys converted from strings to symbols
11
+ #
12
+ def symbolize_keys(hash)
13
+ hash.each_with_object({}) do |(k, v), memo|
14
+ memo[k.to_sym] = v
15
+ end
16
+ end
17
+
18
+ # public: Convert hash keys from strings to symbols in place
19
+ #
20
+ def symbolize_keys!(hash)
21
+ hash.replace symbolize_keys hash
22
+ end
23
+
24
+ # public: Return a new hash with keys as strings
25
+ #
26
+ def stringify_keys(hash)
27
+ hash.each_with_object({}) do |(k, v), memo|
28
+ memo[k.to_s] = v
29
+ end
30
+ end
31
+
32
+ # public: Returns a new hash with all the date values in the into iso8601
33
+ # strings
34
+ #
35
+ def isoify_dates(hash)
36
+ hash.each_with_object({}) do |(k, v), memo|
37
+ memo[k] = datetime_in_iso8601(v)
38
+ end
39
+ end
40
+
41
+ # public: Converts all the date values in the into iso8601 strings in place
42
+ #
43
+ def isoify_dates!(hash)
44
+ hash.replace isoify_dates hash
45
+ end
46
+
47
+ # public: Returns a uid string
48
+ #
49
+ def uid
50
+ arr = SecureRandom.random_bytes(16).unpack('NnnnnN')
51
+ arr[2] = (arr[2] & 0x0fff) | 0x4000
52
+ arr[3] = (arr[3] & 0x3fff) | 0x8000
53
+ '%08x-%04x-%04x-%04x-%04x%08x' % arr
54
+ end
55
+
56
+ def datetime_in_iso8601(datetime)
57
+ case datetime
58
+ when Time
59
+ time_in_iso8601 datetime
60
+ when DateTime
61
+ time_in_iso8601 datetime.to_time
62
+ when Date
63
+ date_in_iso8601 datetime
64
+ else
65
+ datetime
66
+ end
67
+ end
68
+
69
+ def time_in_iso8601(time)
70
+ "#{time.strftime('%Y-%m-%dT%H:%M:%S.%3N')}#{formatted_offset(time, true, 'Z')}"
71
+ end
72
+
73
+ def date_in_iso8601(date)
74
+ date.strftime('%F')
75
+ end
76
+
77
+ def formatted_offset(time, colon = true, alternate_utc_string = nil)
78
+ time.utc? && alternate_utc_string || seconds_to_utc_offset(time.utc_offset, colon)
79
+ end
80
+
81
+ def seconds_to_utc_offset(seconds, colon = true)
82
+ (colon ? UTC_OFFSET_WITH_COLON : UTC_OFFSET_WITHOUT_COLON) % [(seconds < 0 ? '-' : '+'), (seconds.abs / 3600), ((seconds.abs % 3600) / 60)]
83
+ end
84
+
85
+ UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
86
+ UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hightouch
4
+ class Analytics
5
+ VERSION = '0.0.1'
6
+ end
7
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hightouch/analytics/defaults'
4
+ require 'hightouch/analytics/message_batch'
5
+ require 'hightouch/analytics/transport'
6
+ require 'hightouch/analytics/utils'
7
+
8
+ module Hightouch
9
+ class Analytics
10
+ class Worker
11
+ include Hightouch::Analytics::Utils
12
+ include Hightouch::Analytics::Defaults
13
+ include Hightouch::Analytics::Logging
14
+
15
+ # public: Creates a new worker
16
+ #
17
+ # The worker continuously takes messages off the queue
18
+ # and makes requests to the hightouch api
19
+ #
20
+ # queue - Queue synchronized between client and worker
21
+ # write_key - String of the project's Write key
22
+ # options - Hash of worker options
23
+ # batch_size - Fixnum of how many items to send in a batch
24
+ # on_error - Proc of what to do on an error
25
+ #
26
+ def initialize(queue, write_key, options = {})
27
+ symbolize_keys! options
28
+ @queue = queue
29
+ @write_key = write_key
30
+ @on_error = options[:on_error] || proc { |status, error| }
31
+ batch_size = options[:batch_size] || Defaults::MessageBatch::MAX_SIZE
32
+ @batch = MessageBatch.new(batch_size)
33
+ @lock = Mutex.new
34
+ @transport = Transport.new(options)
35
+ end
36
+
37
+ # public: Continuously runs the loop to check for new events
38
+ #
39
+ def run
40
+ until Thread.current[:should_exit]
41
+ return if @queue.empty?
42
+
43
+ @lock.synchronize do
44
+ consume_message_from_queue! until @batch.full? || @queue.empty?
45
+ end
46
+
47
+ res = @transport.send @write_key, @batch
48
+ @on_error.call(res.status, res.error) unless res.status == 200
49
+
50
+ @lock.synchronize { @batch.clear }
51
+ end
52
+ ensure
53
+ @transport.shutdown
54
+ end
55
+
56
+ # public: Check whether we have outstanding requests.
57
+ #
58
+ def is_requesting?
59
+ @lock.synchronize { !@batch.empty? }
60
+ end
61
+
62
+ private
63
+
64
+ def consume_message_from_queue!
65
+ @batch << @queue.pop
66
+ rescue MessageBatch::JSONGenerationError => e
67
+ @on_error.call(-1, e.to_s)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hightouch/analytics/version'
4
+ require 'hightouch/analytics/defaults'
5
+ require 'hightouch/analytics/utils'
6
+ require 'hightouch/analytics/field_parser'
7
+ require 'hightouch/analytics/client'
8
+ require 'hightouch/analytics/worker'
9
+ require 'hightouch/analytics/transport'
10
+ require 'hightouch/analytics/response'
11
+ require 'hightouch/analytics/logging'
12
+ require 'hightouch/analytics/test_queue'
13
+
14
+ module Hightouch
15
+ class Analytics
16
+ # Initializes a new instance of {Hightouch::Analytics::Client}, to which all
17
+ # method calls are proxied.
18
+ #
19
+ # @param options includes options that are passed down to
20
+ # {Hightouch::Analytics::Client#initialize}
21
+ # @option options [Boolean] :stub (false) If true, requests don't hit the
22
+ # server and are stubbed to be successful.
23
+ def initialize(options = {})
24
+ Transport.stub = options[:stub] if options.has_key?(:stub)
25
+ @client = Hightouch::Analytics::Client.new options
26
+ end
27
+
28
+ def method_missing(message, *args, &block)
29
+ if @client.respond_to? message
30
+ @client.send message, *args, &block
31
+ else
32
+ super
33
+ end
34
+ end
35
+
36
+ def respond_to_missing?(method_name, include_private = false)
37
+ @client.respond_to?(method_name) || super
38
+ end
39
+
40
+ include Logging
41
+ end
42
+ end
data/lib/hightouch.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hightouch/analytics'
metadata ADDED
@@ -0,0 +1,170 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: events-sdk-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - HT-SDKS
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-09-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: commander
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.4'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: tzinfo
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.2'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: activesupport
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 5.2.0
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 5.2.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: oj
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 3.6.2
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 3.6.2
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: codecov
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.6'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.6'
125
+ description: Hightouch Events SDK
126
+ email: engineering@hightouch.com
127
+ executables:
128
+ - analytics
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - bin/analytics
133
+ - lib/hightouch.rb
134
+ - lib/hightouch/analytics.rb
135
+ - lib/hightouch/analytics/backoff_policy.rb
136
+ - lib/hightouch/analytics/client.rb
137
+ - lib/hightouch/analytics/defaults.rb
138
+ - lib/hightouch/analytics/field_parser.rb
139
+ - lib/hightouch/analytics/logging.rb
140
+ - lib/hightouch/analytics/message_batch.rb
141
+ - lib/hightouch/analytics/response.rb
142
+ - lib/hightouch/analytics/test_queue.rb
143
+ - lib/hightouch/analytics/transport.rb
144
+ - lib/hightouch/analytics/utils.rb
145
+ - lib/hightouch/analytics/version.rb
146
+ - lib/hightouch/analytics/worker.rb
147
+ homepage: https://github.com/ht-sdks/events-sdk-ruby
148
+ licenses:
149
+ - MIT
150
+ metadata: {}
151
+ post_install_message:
152
+ rdoc_options: []
153
+ require_paths:
154
+ - lib
155
+ required_ruby_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '2.0'
160
+ required_rubygems_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: '0'
165
+ requirements: []
166
+ rubygems_version: 3.1.6
167
+ signing_key:
168
+ specification_version: 4
169
+ summary: Hightouch Events SDK
170
+ test_files: []