amplitude-experiment 1.1.5 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4c7cef3c90843aec26f85cc3706300eb1f6bff3f64c1db9b4162bdf20dc1119
4
- data.tar.gz: bd937b2d414b7b52b3accf57de14ac556a1789f83dd1e5d1625727ae5c9a0222
3
+ metadata.gz: f4b95fab3639837e3bb1223ad3ba71405d9f92384fa73dedca9daa83b8069dac
4
+ data.tar.gz: 3fdb770c922f5e82260cac4f53c1134555b85a3fb5ad91f5fd20956d3a42d300
5
5
  SHA512:
6
- metadata.gz: c927c9bc9808738b641cdf6666e7fa3220dad2d68b275d2d9b4269ebedf5ade2cd421a4e7c77a4283d8c0c0cfd9be2579d209ac9e4430b989dcf6dd864b98b4b
7
- data.tar.gz: b388bc2f602af8031e5fb01cdd862ea31149a142d0fcf559703cdb2a76554cc051a339be8e8670862a129669a09535342ed3a766411c931c9a1c1ec019dbd082
6
+ metadata.gz: 5e3ba791304dc10b7d27d34806d3a6b85b195a86035a0f5656410b0e541280fbf82053b06b8f1a13dd2bd8966d2cfa390b723733d73ce138cc8c4542d5431d29
7
+ data.tar.gz: 5eb707da5065fd9c8399d42967aa02c2f1705e944033e346579a01c4e66fe591c77cbfd812a0f6d1267be25995993a150014334727f80005f0114439a1562c30
@@ -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