juneso-analytics-ruby 2.4.2

Sign up to get free protection for your applications and to get access to all the features.
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: []