juneso-analytics-ruby 2.4.2

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