amplitude-experiment 1.1.4 → 1.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da33da0e966b08a4172225241b5a89b4d0b8c0b91af484b6ebaf33aa8e27fb21
4
- data.tar.gz: 22176a1dcf52fcc64035eb2fc914e891718846e93b83d9f8b29faa5437ca759b
3
+ metadata.gz: f404cf631d8d1063d98e1cb1d84ac87775e6fad2569d1cbdbb4cb09c9d07f788
4
+ data.tar.gz: 6f0583a52122d0c07aa5087817524f7dd1b7a1e669231242ba3938ca96891be1
5
5
  SHA512:
6
- metadata.gz: f41ba9124f0f65dec01fe7223a1ce0ba5337e1e93afc9e73b97a2b73442bd95e71f7c8c3c15afc2ed9aa3fa2c3af758171c7910721dc15a00a299b510810c1e2
7
- data.tar.gz: 6d3a204bae5dc360865018a0fe0cf3dd5c3cc6add4f3ef925556c05c75668a066aa476f2ef07eb475d999d8bdf7b8ad586fc5e593c9777dc7e618096ce800631
6
+ metadata.gz: f5a42786a515308365e9e1b8796a5caca4bd67c00c059e094ef87bca6891169ffbf456238b257eaa9abf3fd947e4f963e445782e9fabcb552ea55bf13e4f5c13
7
+ data.tar.gz: 01af1ba4be8773a21bf31614dd26aef15151eccebaa17c64d326c45d95ff1785623d89422fff43b8c963845a280907d82ddc2071e8ba0ae6c898ea03f27b5897
@@ -19,6 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.require_paths = ['lib']
20
20
  spec.extra_rdoc_files = ['README.md']
21
21
 
22
+ spec.add_development_dependency 'concurrent-ruby', '~> 1.2.2'
22
23
  spec.add_development_dependency 'psych', '~> 4.0'
23
24
  spec.add_development_dependency 'rake', '~> 13.0'
24
25
  spec.add_development_dependency 'rdoc', '= 6.4'
@@ -0,0 +1,54 @@
1
+ module AmplitudeAnalytics
2
+ # Amplitude
3
+ class Amplitude
4
+ attr_reader :configuration
5
+
6
+ def initialize(api_key, configuration: nil)
7
+ @configuration = configuration || Config.new
8
+ @configuration.api_key = api_key
9
+ @timeline = Timeline.new
10
+ @timeline.setup(self)
11
+ add(AmplitudeDestinationPlugin.new)
12
+ add(ContextPlugin.new)
13
+ register_on_exit
14
+ end
15
+
16
+ def track(event)
17
+ @timeline.process(event)
18
+ end
19
+
20
+ def flush
21
+ @timeline.flush
22
+ end
23
+
24
+ def add(plugin)
25
+ @timeline.add(plugin)
26
+ plugin.setup(self)
27
+ self
28
+ end
29
+
30
+ def remove(plugin)
31
+ @timeline.remove(plugin)
32
+ self
33
+ end
34
+
35
+ def shutdown
36
+ @configuration.opt_out = true
37
+ @timeline.shutdown
38
+ end
39
+
40
+ private
41
+
42
+ def register_on_exit
43
+ if Thread.respond_to?(:_at_exit)
44
+ begin
45
+ at_exit { shutdown }
46
+ rescue StandardError
47
+ @configuration.logger.warning('register for exit fail')
48
+ end
49
+ else
50
+ at_exit { shutdown }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,78 @@
1
+ require 'logger'
2
+ module AmplitudeAnalytics
3
+ # Config
4
+ class Config
5
+ attr_accessor :api_key, :flush_interval_millis, :flush_max_retries,
6
+ :logger, :min_id_length, :callback, :server_zone, :use_batch,
7
+ :storage_provider, :opt_out, :ingestion_metadata
8
+
9
+ def initialize(api_key: nil, flush_queue_size: FLUSH_QUEUE_SIZE,
10
+ flush_interval_millis: FLUSH_INTERVAL_MILLIS,
11
+ flush_max_retries: FLUSH_MAX_RETRIES,
12
+ logger: Logger.new($stdout, progname: LOGGER_NAME, level: Logger::ERROR),
13
+ min_id_length: nil, callback: nil, server_zone: DEFAULT_ZONE,
14
+ use_batch: false, server_url: nil,
15
+ storage_provider: InMemoryStorageProvider.new, ingestion_metadata: nil)
16
+ @api_key = api_key
17
+ @flush_queue_size = flush_queue_size
18
+ @flush_size_divider = 1
19
+ @flush_interval_millis = flush_interval_millis
20
+ @flush_max_retries = flush_max_retries
21
+ @logger = logger
22
+ @min_id_length = min_id_length
23
+ @callback = callback
24
+ @server_zone = server_zone
25
+ @use_batch = use_batch
26
+ @server_url = server_url
27
+ @storage_provider = storage_provider
28
+ @opt_out = false
29
+ @ingestion_metadata = ingestion_metadata
30
+ end
31
+
32
+ def storage
33
+ @storage_provider.storage
34
+ end
35
+
36
+ def valid?
37
+ @api_key && @flush_queue_size > 0 && @flush_interval_millis > 0 && min_id_length_valid?
38
+ end
39
+
40
+ def min_id_length_valid?
41
+ @min_id_length.nil? || (@min_id_length.is_a?(Integer) && @min_id_length > 0)
42
+ end
43
+
44
+ def flush_queue_size
45
+ [@flush_queue_size / @flush_size_divider, 1].max
46
+ end
47
+
48
+ def flush_queue_size=(size)
49
+ @flush_queue_size = size
50
+ @flush_size_divider = 1
51
+ end
52
+
53
+ def server_url
54
+ @url || (
55
+ if use_batch
56
+ SERVER_URL[@server_zone][BATCH]
57
+ else
58
+ SERVER_URL[@server_zone][HTTP_V2]
59
+ end)
60
+ end
61
+
62
+ def server_url=(url)
63
+ @url = url
64
+ end
65
+
66
+ def options
67
+ { 'min_id_length' => @min_id_length } if min_id_length_valid? && @min_id_length
68
+ end
69
+
70
+ def increase_flush_divider
71
+ @flush_size_divider += 1
72
+ end
73
+
74
+ def reset_flush_divider
75
+ @flush_size_divider = 1
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,45 @@
1
+ module AmplitudeAnalytics
2
+ SDK_LIBRARY = 'amplitude-experiment-ruby'.freeze
3
+ SDK_VERSION = '1.1.5'.freeze
4
+
5
+ EU_ZONE = 'EU'.freeze
6
+ DEFAULT_ZONE = 'US'.freeze
7
+ BATCH = 'batch'.freeze
8
+ HTTP_V2 = 'v2'.freeze
9
+ SERVER_URL = {
10
+ EU_ZONE => {
11
+ BATCH => 'https://api.eu.amplitude.com/batch',
12
+ HTTP_V2 => 'https://api.eu.amplitude.com/2/httpapi'
13
+ },
14
+ DEFAULT_ZONE => {
15
+ BATCH => 'https://api2.amplitude.com/batch',
16
+ HTTP_V2 => 'https://api2.amplitude.com/2/httpapi'
17
+ }
18
+ }.freeze
19
+ LOGGER_NAME = 'amplitude'.freeze
20
+
21
+ MAX_PROPERTY_KEYS = 1024
22
+ MAX_STRING_LENGTH = 1024
23
+ FLUSH_QUEUE_SIZE = 200
24
+ FLUSH_INTERVAL_MILLIS = 10_000
25
+ FLUSH_MAX_RETRIES = 12
26
+ CONNECTION_TIMEOUT = 10.0 # seconds float
27
+ MAX_BUFFER_CAPACITY = 20_000
28
+
29
+ # PluginType
30
+ class PluginType
31
+ BEFORE = 0
32
+ ENRICHMENT = 1
33
+ DESTINATION = 2
34
+
35
+ def self.name(value)
36
+ mapping = {
37
+ BEFORE => 'BEFORE',
38
+ ENRICHMENT => 'ENRICHMENT',
39
+ DESTINATION => 'DESTINATION'
40
+ }
41
+
42
+ mapping[value]
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,244 @@
1
+ require 'json'
2
+
3
+ module AmplitudeAnalytics
4
+ # IngestionMetadata
5
+ class IngestionMetadata
6
+ INGESTION_METADATA_KEY_MAPPING = {
7
+ 'source_name' => ['source_name', String],
8
+ 'source_version' => ['source_version', String]
9
+ }.freeze
10
+
11
+ attr_accessor :source_name, :source_version
12
+
13
+ def initialize(source_name: nil, source_version: nil)
14
+ @source_name = source_name
15
+ @source_version = source_version
16
+ end
17
+
18
+ def body
19
+ result = {}
20
+ INGESTION_METADATA_KEY_MAPPING.each do |key, mapping|
21
+ next unless instance_variable_defined?("@#{key}") && !instance_variable_get("@#{key}").nil?
22
+
23
+ value = instance_variable_get("@#{key}")
24
+ if value.is_a?(mapping[1])
25
+ result[mapping[0]] = value
26
+ else
27
+ puts "#{self.class}.#{key} expected #{mapping[1]} but received #{value.class}."
28
+ end
29
+ end
30
+ result
31
+ end
32
+ end
33
+
34
+ # EventOptions
35
+ class EventOptions
36
+ EVENT_KEY_MAPPING = {
37
+ 'user_id' => ['user_id', String],
38
+ 'device_id' => ['device_id', String],
39
+ 'event_type' => ['event_type', String],
40
+ 'time' => ['time', Integer],
41
+ 'event_properties' => ['event_properties', Hash],
42
+ 'user_properties' => ['user_properties', Hash],
43
+ 'groups' => ['groups', Hash],
44
+ 'app_version' => ['app_version', String],
45
+ 'platform' => ['platform', String],
46
+ 'os_name' => ['os_name', String],
47
+ 'os_version' => ['os_version', String],
48
+ 'device_brand' => ['device_brand', String],
49
+ 'device_manufacturer' => ['device_manufacturer', String],
50
+ 'device_model' => ['device_model', String],
51
+ 'carrier' => ['carrier', String],
52
+ 'country' => ['country', String],
53
+ 'region' => ['region', String],
54
+ 'city' => ['city', String],
55
+ 'dma' => ['dma', String],
56
+ 'language' => ['language', String],
57
+ 'price' => ['price', Float],
58
+ 'quantity' => ['quantity', Integer],
59
+ 'revenue' => ['revenue', Float],
60
+ 'product_id' => ['productId', String],
61
+ 'revenue_type' => ['revenueType', String],
62
+ 'location_lat' => ['location_lat', Float],
63
+ 'location_lng' => ['location_lng', Float],
64
+ 'ip' => ['ip', String],
65
+ 'idfa' => ['idfa', String],
66
+ 'idfv' => ['idfv', String],
67
+ 'adid' => ['adid', String],
68
+ 'android_id' => ['android_id', String],
69
+ 'event_id' => ['event_id', Integer],
70
+ 'session_id' => ['session_id', Integer],
71
+ 'insert_id' => ['insert_id', String],
72
+ 'library' => ['library', String],
73
+ 'ingestion_metadata' => ['ingestion_metadata', IngestionMetadata],
74
+ 'group_properties' => ['group_properties', Hash],
75
+ 'partner_id' => ['partner_id', String],
76
+ 'version_name' => ['version_name', String]
77
+ }.freeze
78
+
79
+ attr_accessor :user_id, :device_id, :event_type, :time, :event_properties, :user_properties,
80
+ :groups, :app_version, :platform, :os_name, :os_version, :device_brand,
81
+ :device_manufacturer, :device_model, :carrier, :country, :region, :city,
82
+ :dma, :language, :price, :quantity, :revenue, :product_id, :revenue_type,
83
+ :location_lat, :location_lng, :ip, :idfa, :idfv, :adid, :android_id,
84
+ :event_id, :session_id, :insert_id, :library, :ingestion_metadata,
85
+ :group_properties, :partner_id, :version_name, :retry
86
+
87
+ def initialize(user_id: nil, device_id: nil, time: nil, event_properties: nil,
88
+ user_properties: nil, groups: nil, app_version: nil, platform: nil, os_name: nil,
89
+ os_version: nil, device_brand: nil, device_manufacturer: nil, device_model: nil,
90
+ carrier: nil, country: nil, region: nil, city: nil, dma: nil, language: nil,
91
+ price: nil, quantity: nil, revenue: nil, product_id: nil, revenue_type: nil,
92
+ location_lat: nil, location_lng: nil, ip: nil, idfa: nil, idfv: nil, adid: nil,
93
+ android_id: nil, event_id: nil, session_id: nil, insert_id: nil,
94
+ ingestion_metadata: nil, group_properties: nil, partner_id: nil, version_name: nil,
95
+ callback: nil)
96
+ @user_id = user_id
97
+ @device_id = device_id
98
+ @event_type = event_type
99
+ @time = time
100
+ @event_properties = event_properties
101
+ @user_properties = user_properties
102
+ @groups = groups
103
+ @app_version = app_version
104
+ @platform = platform
105
+ @os_name = os_name
106
+ @os_version = os_version
107
+ @device_brand = device_brand
108
+ @device_manufacturer = device_manufacturer
109
+ @device_model = device_model
110
+ @carrier = carrier
111
+ @country = country
112
+ @region = region
113
+ @city = city
114
+ @dma = dma
115
+ @language = language
116
+ @price = price
117
+ @quantity = quantity
118
+ @revenue = revenue
119
+ @product_id = product_id
120
+ @revenue_type = revenue_type
121
+ @location_lat = location_lat
122
+ @location_lng = location_lng
123
+ @ip = ip
124
+ @idfa = idfa
125
+ @idfv = idfv
126
+ @adid = adid
127
+ @android_id = android_id
128
+ @event_id = event_id
129
+ @session_id = session_id
130
+ @insert_id = insert_id
131
+ @ingestion_metadata = ingestion_metadata
132
+ @group_properties = group_properties
133
+ @partner_id = partner_id
134
+ @version_name = version_name
135
+ @event_callback = callback
136
+ @retry = 0
137
+ end
138
+
139
+ def [](key)
140
+ instance_variable_get("@#{key}")
141
+ end
142
+
143
+ def []=(key, value)
144
+ send("#{key}=", value) if verify_property(key, value)
145
+ end
146
+
147
+ def include?(key)
148
+ instance_variable_defined?("@#{key}") && !instance_variable_get("@#{key}").nil?
149
+ end
150
+
151
+ def valid_properties?(key, value)
152
+ return false unless key.is_a?(String)
153
+
154
+ if value.is_a?(Array)
155
+ result = true
156
+ value.each do |element|
157
+ return false if element.is_a?(Array)
158
+
159
+ if element.is_a?(Hash)
160
+ result &&= valid_object?(element)
161
+ elsif !element.is_a?(Numeric) && !element.is_a?(String) && !element.is_a?(TrueClass) && !element.is_a?(FalseClass)
162
+ result = false
163
+ end
164
+ break unless result
165
+ end
166
+ return result
167
+ end
168
+
169
+ return valid_object?(value) if value.is_a?(Hash)
170
+
171
+ value.is_a?(TrueClass) || value.is_a?(FalseClass) ||
172
+ value.is_a?(Numeric) || value.is_a?(String) || value.is_a?(Symbol)
173
+ end
174
+
175
+ def valid_object?(obj)
176
+ obj.each do |key, value|
177
+ return false unless valid_properties?(key, value)
178
+ end
179
+ true
180
+ end
181
+
182
+ def verify_property(key, value)
183
+ return true if value.nil?
184
+
185
+ unless instance_variable_defined?("@#{key}")
186
+ AmplitudeAnalytics.logger.error("Unexpected event property key: #{key}")
187
+ return false
188
+ end
189
+
190
+ expected_type = EVENT_KEY_MAPPING[key][1]
191
+ unless value.is_a?(expected_type)
192
+ AmplitudeAnalytics.logger.error("Event property #{key} expected #{expected_type} but received #{value.class}.")
193
+ return false
194
+ end
195
+
196
+ return valid_object?(value) if value.is_a?(Hash)
197
+
198
+ true
199
+ end
200
+
201
+ def event_body
202
+ event_body = {}
203
+ EVENT_KEY_MAPPING.each do |key, mapping|
204
+ next unless include?(key) && self[key]
205
+
206
+ value = self[key]
207
+ if value.is_a?(mapping[1])
208
+ event_body[mapping[0]] = value
209
+ else
210
+ puts "#{self.class}.#{key} expected #{mapping[1]} but received #{value.class}."
211
+ end
212
+ end
213
+ event_body['ingestion_metadata'] = @ingestion_metadata.body if @ingestion_metadata.respond_to?(:body)
214
+ %w[user_properties event_properties group_properties].each do |properties|
215
+ next unless event_body[properties]
216
+ end
217
+ AmplitudeAnalytics.truncate(event_body.sort.to_h)
218
+ end
219
+
220
+ def callback(status_code, message = nil)
221
+ @event_callback.call(self, status_code, message) if @event_callback.respond_to?(:call)
222
+ end
223
+
224
+ def to_s
225
+ JSON.generate(event_body)
226
+ end
227
+ end
228
+
229
+ # BaseEvent
230
+ class BaseEvent < EventOptions
231
+ def initialize(event_type, **kwargs)
232
+ @event_type = event_type
233
+ super(**kwargs)
234
+ end
235
+
236
+ def load_event_options(event_options)
237
+ return if event_options.nil?
238
+
239
+ EVENT_KEY_MAPPING.each_key do |key|
240
+ self[key] = Marshal.load(Marshal.dump(event_options[key])) if event_options.include?(key)
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,15 @@
1
+ module AmplitudeAnalytics
2
+ # InvalidEventError
3
+ class InvalidEventError < StandardError
4
+ def initialize(message = 'Invalid event.')
5
+ super(message)
6
+ end
7
+ end
8
+
9
+ # InvalidAPIKeyError
10
+ class InvalidAPIKeyError < StandardError
11
+ def initialize(message = 'Invalid API key.')
12
+ super(message)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,161 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+
5
+ module AmplitudeAnalytics
6
+ JSON_HEADER = {
7
+ 'Content-Type': 'application/json; charset=UTF-8',
8
+ Accept: '*/*'
9
+ }.freeze
10
+
11
+ # HttpStatus
12
+ class HttpStatus
13
+ attr_reader :value
14
+
15
+ def initialize(value)
16
+ @value = value
17
+ end
18
+
19
+ SUCCESS = new(200)
20
+ INVALID_REQUEST = new(400)
21
+ TIMEOUT = new(408)
22
+ PAYLOAD_TOO_LARGE = new(413)
23
+ TOO_MANY_REQUESTS = new(429)
24
+ FAILED = new(500)
25
+ UNKNOWN = new(-1)
26
+ end
27
+
28
+ # Response
29
+ class Response
30
+ attr_accessor :status, :code, :body
31
+
32
+ def initialize(status: HttpStatus::UNKNOWN, body: nil)
33
+ @status = status
34
+ @code = status.value
35
+ @body = body || {}
36
+ end
37
+
38
+ def parse(res)
39
+ res_body = JSON.parse(res.body)
40
+ @code = res_body['code']
41
+ @status = get_status(@code)
42
+ @body = res_body
43
+ self
44
+ end
45
+
46
+ def get_status(code)
47
+ case code
48
+ when 200
49
+ HttpStatus::SUCCESS
50
+ when 400
51
+ HttpStatus::INVALID_REQUEST
52
+ when 408
53
+ HttpStatus::TIMEOUT
54
+ when 413
55
+ HttpStatus::PAYLOAD_TOO_LARGE
56
+ when 429
57
+ HttpStatus::TOO_MANY_REQUESTS
58
+ when 500
59
+ HttpStatus::FAILED
60
+ else
61
+ HttpStatus::UNKNOWN
62
+ end
63
+ end
64
+
65
+ def error
66
+ @body['error'] if @body.key?('error')
67
+ end
68
+
69
+ def missing_field
70
+ @body['missing_field'] if @body.key?('missing_field')
71
+ end
72
+
73
+ def events_with_invalid_fields
74
+ @body['events_with_invalid_fields'] if @body.key?('events_with_invalid_fields')
75
+ end
76
+
77
+ def events_with_missing_fields
78
+ @body['events_with_missing_fields'] if @body.key?('events_with_missing_fields')
79
+ end
80
+
81
+ def events_with_invalid_id_lengths
82
+ @body['events_with_invalid_id_lengths'] if @body.key?('events_with_invalid_id_lengths')
83
+ end
84
+
85
+ def silenced_events
86
+ @body['silenced_events'] if @body.key?('silenced_events')
87
+ end
88
+
89
+ def throttled_events
90
+ @body['throttled_events'] if @body.key?('throttled_events')
91
+ end
92
+
93
+ def exceed_daily_quota(event)
94
+ return true if @body.key?('exceeded_daily_quota_users') && @body['exceeded_daily_quota_users'].include?(event.user_id)
95
+ return true if @body.key?('exceeded_daily_quota_devices') && @body['exceeded_daily_quota_devices'].include?(event.device_id)
96
+
97
+ false
98
+ end
99
+
100
+ def invalid_or_silenced_index
101
+ result = Set.new
102
+ result.merge(@body['events_with_missing_fields'].values.flatten) if @body.key?('events_with_missing_fields')
103
+ result.merge(@body['events_with_invalid_fields'].values.flatten) if @body.key?('events_with_invalid_fields')
104
+ result.merge(@body['events_with_invalid_id_lengths'].values.flatten) if @body.key?('events_with_invalid_id_lengths')
105
+ result.merge(@body['silenced_events']) if @body.key?('silenced_events')
106
+ result.to_a
107
+ end
108
+
109
+ def self.get_status(code)
110
+ case code
111
+ when 200..299
112
+ HttpStatus::SUCCESS
113
+ when 429
114
+ HttpStatus::TOO_MANY_REQUESTS
115
+ when 413
116
+ HttpStatus::PAYLOAD_TOO_LARGE
117
+ when 408
118
+ HttpStatus::TIMEOUT
119
+ when 400..499
120
+ HttpStatus::INVALID_REQUEST
121
+ when 500..Float::INFINITY
122
+ HttpStatus::FAILED
123
+ else
124
+ HttpStatus::UNKNOWN
125
+ end
126
+ end
127
+ end
128
+
129
+ # HttpClient
130
+ class HttpClient
131
+ def post(url, payload, header = nil)
132
+ result = Response.new
133
+ begin
134
+ uri = URI.parse(url)
135
+ http = Net::HTTP.new(uri.host || '', uri.port)
136
+ http.use_ssl = uri.scheme == 'https'
137
+
138
+ headers = header || JSON_HEADER
139
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
140
+ request.body = payload
141
+ res = http.request(request)
142
+ result.parse(res)
143
+ rescue Net::ReadTimeout
144
+ result.code = 408
145
+ result.status = HttpStatus::TIMEOUT
146
+ rescue Net::HTTPError => e
147
+ begin
148
+ result.parse(e)
149
+ rescue StandardError
150
+ result = Response.new
151
+ result.code = e.response.code.to_i
152
+ result.status = Response.get_status(e.response.code.to_i)
153
+ result.body = { 'error' => e.response.message }
154
+ end
155
+ rescue Net::OpenTimeout => e
156
+ result.body = { 'error' => e.message }
157
+ end
158
+ result
159
+ end
160
+ end
161
+ end