leanplum_api 1.3.1

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
+ SHA1:
3
+ metadata.gz: eaef8b007ad46754ae7107b22f86db959f8805c9
4
+ data.tar.gz: 503030e7e4f4a85640e42aa2e2d60da7911bad5d
5
+ SHA512:
6
+ metadata.gz: 3d9b7d5ad103922bf4888953aaad2e5130b98c2136a8b18a0b02ba30e9d55d6d2d041403eddad4799457c51290b42491570a42a9a68bb63315b3843130cf0814
7
+ data.tar.gz: dc14c91eb847b399af97a187bed901f948f88a34462539a2489d72fd587c07a885537e3b3b2f34dac4e8bad3dd4795f0a918ccbd0395e3d99bd64046f876efa7
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Lumos Labs, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # leanplum_api
2
+
3
+ Gem for the Leanplum API.
4
+
5
+ ## Notes
6
+
7
+ Leanplum calls it a REST API but it is not very RESTful.
8
+
9
+ The gem uses the ```multi``` method with a POST for all requests except data export. Check Leanplum's docs for more information on ```multi```.
10
+
11
+ Tested with Leanplum API version 1.0.6.
12
+
13
+ required_ruby_version is set to 1.9 but this code has only been tested with Ruby 2.1.5!
14
+
15
+ ## Configuration
16
+
17
+ You need to obtain (at a minimum) the PRODUCTION and APP_ID from Leanplum. You may also want to configure the DATA_EXPORT_KEY, CONTENT_READ_ONLY_KEY, and DEVELOPMENT_KEY if you plan on calling methods that require those keys. Then you can setup the gem for use in your application like so:
18
+
19
+ ```ruby
20
+ require 'leanplum_api'
21
+
22
+ LeanplumApi.configure do |config|
23
+ config.production_key = 'MY_CLIENT_KEY'
24
+ config.app_id = 'MY_APP_ID'
25
+ config.data_export_key = 'MY_DATA_KEY' # Optional; necessary only if you want to call data export methods.
26
+ config.content_read_only_key = 'MY_CONTENT_KEY' # Optional; necessary for retrieving AB test info
27
+ config.development_key = 'MY_CONTENT_KEY' # Optional; needed for resetting anomalous events
28
+
29
+ # Optional configuration variables
30
+ config.log_path = '/log/path' # Defaults to 'log/'
31
+ attr_accessor :timeout_seconds # Defaults to 600
32
+ config.api_version # Defaults to 1.0.6
33
+ attr_accessor :developer_mode # Defaults to false
34
+
35
+ # S3 export required options
36
+ config.s3_bucket_name = 'my_bucket'
37
+ config.s3_access_id = 'access_id'
38
+ config.s3_access_key = 'access_key'
39
+
40
+ # Set this to true to send events and user attributes to the test environment
41
+ # Defaults to false. See "Debugging" below for more info.
42
+ config.developer_mode = true
43
+ end
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ Tracking events and user attributes:
49
+
50
+ ```ruby
51
+ api = LeanplumApi::API.new
52
+
53
+ # You must provide either :user_id or :device_id for requests involving
54
+ # attribute updates or event tracking.
55
+ attribute_hash = {
56
+ user_id: 12345,
57
+ first_name: 'Mike',
58
+ last_name: 'Jones',
59
+ gender: 'm',
60
+ birthday: Date.today, # Dates and times in user attributes will be formatted as strings; Leanplum doesn't support date or time types
61
+ email: 'still_tippin@test.com'
62
+ }
63
+ api.set_user_attributes(attribute_hash)
64
+
65
+ # You must also provide the :event property for event tracking
66
+ event = {
67
+ user_id: 12345,
68
+ event: 'purchase',
69
+ time: Time.now.utc, # Event timestamps will be converted to epoch seconds by the gem.
70
+ params: {
71
+ 'some_event_property' => 'boss_hog_on_candy'
72
+ }
73
+ }
74
+ api.track_events(event)
75
+
76
+ # You can also track events and user attributes at the same time
77
+ api.track_multi(event, attribute_hash)
78
+ ```
79
+
80
+ Data export:
81
+ ```ruby
82
+ api = LeanplumApi::API.new
83
+ job_id = api.export_data(start_time, end_time)
84
+ response = wait_for_job(job_id)
85
+ ```
86
+
87
+ ## Logging
88
+
89
+ When you instantiate a ```LeanplumApi::API``` object, you can pass a ```Logger``` object to redirect the logging as you see fit.
90
+
91
+ ```ruby
92
+ api = LeanplumApi::API.new(logger: Logger.new('/path/to/my/log_file.log))
93
+ ```
94
+
95
+ Alternatively, you can configure a log_path in the configure block.
96
+ ```ruby
97
+ LeanplumApi.configure do |config|
98
+ config.log_path = '/path/to/my/logs'
99
+ end
100
+ ```
101
+
102
+ And logs will be sent to ```/path/to/my/logs/{PID}_leanplum_{timestamp}.log```
103
+
104
+ The default log_path is ```log/```
105
+
106
+ ## Tests
107
+
108
+ To run tests, you must set the LEANPLUM_PRODUCTION_KEY, LEANPLUM_APP_ID, LEANPLUM_CONTENT_READ_ONLY_KEY, LEANPLUM_DEVELOPMENT_KEY, and LEANPLUM_DATA_EXPORT_KEY environment variables (preferably to some development only keys) to something and then run rspec.
109
+ Because of the nature of VCR/Webmock, you can set them to anything (including invalid keys) as long as you are not changing anything substantive or writing new specs. If you want to make substantive changes/add new specs, VCR will need to be able to generate fixture data so you will need to use a real set of Leanplum keys.
110
+
111
+ > BE AWARE THAT IF YOU WRITE A NEW SPEC OR DELETE A VCR FILE, IT'S POSSIBLE THAT REAL DATA WILL BE WRITTEN TO THE LEANPLUM_APP_ID YOU CONFIGURE! Certainly a real request will be made to rebuild the VCR file, and while specs run with ```devMode=true```, it's usually a good idea to create a fake app for testing/running specs against.
112
+
113
+ ```bash
114
+ export LEANPLUM_PRODUCTION_KEY=dev_somethingsomeg123456
115
+ export LEANPLUM_APP_ID=app_somethingsomething2039410238
116
+ export LEANPLUM_DATA_EXPORT_KEY=data_something_3238mmmX
117
+ export LEANPLUM_CONTENT_READ_ONLY_KEY=sometingsome23xx9
118
+ export LEANPLUM_DEVELOPMENT_KEY=sometingsome23xx923n23i
119
+
120
+ bundle exec rspec
121
+ ```
122
+
123
+ ## Debugging
124
+
125
+ The LEANPLUM_API_DEBUG environment variable will trigger full printouts of Faraday's debug output to STDERR and to the configured logger.
126
+
127
+ ```bash
128
+ cd /my/app
129
+ export LEANPLUM_API_DEBUG=true
130
+ bundle exec rails whatever
131
+ ```
132
+
133
+ You can also configure "developer mode". This will use the "devMode=true" parameter on all requests, which sends them to a separate queue (and probably means actions logged as development tests don't count towards your bill).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require 'bundler/gem_tasks'
2
+
3
+ require 'rspec/core'
4
+ require 'rspec/core/rake_task'
5
+ RSpec::Core::RakeTask.new(:spec) do |spec|
6
+ spec.pattern = FileList['spec/**/*_spec.rb']
7
+ end
8
+
9
+ task prep: %w(spec)
10
+
11
+ task default: :spec
@@ -0,0 +1,15 @@
1
+ require 'faraday'
2
+ require 'active_support/all'
3
+
4
+ require 'leanplum_api/api'
5
+ require 'leanplum_api/configuration'
6
+ require 'leanplum_api/content_read_only'
7
+ require 'leanplum_api/data_export'
8
+ require 'leanplum_api/development'
9
+ require 'leanplum_api/exception'
10
+ require 'leanplum_api/http'
11
+ require 'leanplum_api/logger'
12
+ require 'leanplum_api/version'
13
+
14
+ module LeanplumApi
15
+ end
@@ -0,0 +1,251 @@
1
+ module LeanplumApi
2
+ class API
3
+ EXPORT_PENDING = 'PENDING'
4
+ EXPORT_RUNNING = 'RUNNING'
5
+ EXPORT_FINISHED = 'FINISHED'
6
+
7
+ def initialize(options = {})
8
+ fail 'LeanplumApi not configured yet!' unless LeanplumApi.configuration
9
+
10
+ @logger = options[:logger] || LeanplumApiLogger.new(File.join(LeanplumApi.configuration.log_path, "#{$$}_leanplum_#{Time.now.utc.strftime('%Y-%m-%d_%H:%M:%S')}.log"))
11
+ @http = LeanplumApi::HTTP.new(logger: @logger)
12
+ end
13
+
14
+ def set_user_attributes(user_attributes, options = {})
15
+ track_multi(nil, user_attributes, options)
16
+ end
17
+
18
+ def track_events(events, options = {})
19
+ track_multi(events, nil, options)
20
+ end
21
+
22
+ # This method is for tracking events and/or updating user attributes at the same time, batched together like leanplum
23
+ # recommends.
24
+ # Set the :force_anomalous_override to catch warnings from leanplum about anomalous events and force them to not
25
+ # be considered anomalous
26
+ def track_multi(events = nil, user_attributes = nil, options = {})
27
+ events = arrayify(events)
28
+ user_attributes = arrayify(user_attributes)
29
+
30
+ request_data = user_attributes.map { |h| build_user_attributes_hash(h) } + events.map { |h| build_event_attributes_hash(h) }
31
+ response = @http.post(request_data)
32
+ validate_response(events + user_attributes, response)
33
+
34
+ if options[:force_anomalous_override]
35
+ user_ids_to_reset = []
36
+ response.body['response'].each_with_index do |indicator, i|
37
+ if indicator['warning'] && indicator['warning']['message'] =~ /Anomaly detected/i
38
+ user_ids_to_reset << (events + user_attributes)[i][:user_id]
39
+ end
40
+ end
41
+ reset_anomalous_users(user_ids_to_reset)
42
+ end
43
+ end
44
+
45
+ # Returns the jobId
46
+ # Leanplum has confirmed that using startTime and endTime, especially trying to be relatively up to the minute,
47
+ # leads to sort of unprocessed information that can be incomplete.
48
+ # They recommend using the automatic export to S3 if possible.
49
+ def export_data(start_time, end_time = nil)
50
+ fail "Start time #{start_time} after end time #{end_time}" if end_time && start_time > end_time
51
+ @logger.info("Requesting data export from #{start_time} to #{end_time}...")
52
+
53
+ # Because of open questions about how startTime and endTime work (or don't work, as the case may be), we
54
+ # only want to pass the dates unless start and end times are specifically requested.
55
+ params = { action: 'exportData', startDate: start_time.strftime('%Y%m%d') }
56
+ params[:startTime] = start_time.strftime('%s') if start_time.is_a?(DateTime) || start_time.is_a?(Time)
57
+ if end_time
58
+ params[:endDate] = end_time.strftime('%Y%m%d')
59
+ params[:endTime] = end_time.strftime('%s') if end_time.is_a?(DateTime) || end_time.is_a?(Time)
60
+ end
61
+
62
+ # Handle optional S3 export params
63
+ if LeanplumApi.configuration.s3_bucket_name
64
+ fail 's3_bucket_name set but s3_access_id not configured!' unless LeanplumApi.configuration.s3_access_id
65
+ fail 's3_bucket_name set but s3_access_key not configured!' unless LeanplumApi.configuration.s3_access_key
66
+
67
+ params.merge!(
68
+ s3BucketName: LeanplumApi.configuration.s3_bucket_name,
69
+ s3AccessId: LeanplumApi.configuration.s3_access_id,
70
+ s3AccessKey: LeanplumApi.configuration.s3_access_key
71
+ )
72
+ params.merge!(s3ObjectPrefix: LeanplumApi.configuration.s3_object_prefix) if LeanplumApi.configuration.s3_object_prefix
73
+ end
74
+
75
+ response = data_export_connection.get(params).body['response'].first
76
+ if response['success'] == true
77
+ response['jobId']
78
+ else
79
+ fail "No success message! Response: #{response}"
80
+ end
81
+ end
82
+
83
+ # See leanplum docs.
84
+ # The segment syntax is identical to that produced by the "Insert Value" feature on the dashboard.
85
+ # Examples: 'Country = "US"', '{Country = “US”} and {App version = 1}'.
86
+ def export_users(segment, ab_test_id)
87
+ data_export_connection.get(action: 'exportUsers', segment: segment, ab_test_id: ab_test_id)
88
+ end
89
+
90
+ def get_export_results(job_id)
91
+ response = data_export_connection.get(action: 'getExportResults', jobId: job_id).body['response'].first
92
+ if response['state'] == EXPORT_FINISHED
93
+ @logger.info("Export finished.")
94
+ @logger.debug(" Response: #{response}")
95
+ {
96
+ files: response['files'],
97
+ number_of_sessions: response['numSessions'],
98
+ number_of_bytes: response['numBytes'],
99
+ state: response['state'],
100
+ s3_copy_status: response['s3CopyStatus']
101
+ }
102
+ else
103
+ { state: response['state'] }
104
+ end
105
+ end
106
+
107
+ def wait_for_job(job_id, polling_interval = 60)
108
+ while get_export_results(job_id)[:state] != EXPORT_FINISHED
109
+ @logger.debug("Polling job #{job_id}: #{get_export_results(job_id)}")
110
+ sleep(polling_interval)
111
+ end
112
+ get_export_results(job_id)
113
+ end
114
+
115
+ def export_user(user_id)
116
+ data_export_connection.get(action: 'exportUser', userId: user_id).body['response'].first['userAttributes']
117
+ end
118
+
119
+ def get_ab_tests(only_recent = false)
120
+ content_read_only_connection.get(action: 'getAbTests', recent: only_recent).body['response'].first['abTests']
121
+ end
122
+
123
+ def get_ab_test(ab_test_id)
124
+ content_read_only_connection.get(action: 'getAbTest', id: ab_test_id).body['response'].first['abTest']
125
+ end
126
+
127
+ def get_variant(variant_id)
128
+ content_read_only_connection.get(action: 'getVariant', id: variant_id).body['response'].first['variant']
129
+ end
130
+
131
+ def get_messages(only_recent = false)
132
+ content_read_only_connection.get(action: 'getMessages', recent: only_recent).body['response'].first['messages']
133
+ end
134
+
135
+ def get_message(message_id)
136
+ content_read_only_connection.get(action: 'getMessage', id: message_id).body['response'].first['message']
137
+ end
138
+
139
+ def get_vars(user_id)
140
+ @http.get(action: 'getVars', userId: user_id).body['response'].first['vars']
141
+ end
142
+
143
+ # If you pass old events OR users with old date attributes (i.e. create_date for an old users), leanplum will mark them 'anomalous'
144
+ # and exclude them from your data set.
145
+ # Calling this method after you pass old events will fix that for all events for the specified user_id
146
+ # For some reason this API feature requires the developer key
147
+ def reset_anomalous_users(user_ids)
148
+ user_ids = arrayify(user_ids)
149
+ request_data = user_ids.map { |user_id| { 'action' => 'setUserAttributes', 'resetAnomalies' => true, 'userId' => user_id } }
150
+ response = development_connection.post(request_data)
151
+ validate_response(request_data, response)
152
+ end
153
+
154
+ private
155
+
156
+ # Only instantiated for data export endpoint calls
157
+ def data_export_connection
158
+ @data_export ||= LeanplumApi::DataExport.new(logger: @logger)
159
+ end
160
+
161
+ # Only instantiated for ContentReadOnly calls (AB tests)
162
+ def content_read_only_connection
163
+ @content_read_only ||= LeanplumApi::ContentReadOnly.new(logger: @logger)
164
+ end
165
+
166
+ def development_connection
167
+ @development ||= LeanplumApi::Development.new(logger: @logger)
168
+ end
169
+
170
+ def extract_user_id_or_device_id_hash(hash)
171
+ user_id = hash['user_id'] || hash[:user_id]
172
+ device_id = hash['device_id'] || hash[:device_id]
173
+ fail "No device_id or user_id in hash #{hash}" unless user_id || device_id
174
+
175
+ user_id ? { 'userId' => user_id } : { 'deviceId' => device_id }
176
+ end
177
+
178
+ # Action can be any command that takes a userAttributes param. "start" (a session) is the other command that most
179
+ # obviously takes userAttributes.
180
+ def build_user_attributes_hash(user_hash, action = 'setUserAttributes')
181
+ extract_user_id_or_device_id_hash(user_hash).merge(
182
+ 'action' => action,
183
+ 'userAttributes' => turn_date_and_time_values_to_strings(user_hash).reject { |k,v| k.to_s =~ /^(user_id|device_id)$/ }
184
+ )
185
+ end
186
+
187
+ # Events have a :user_id or :device id, a name (:event) and an optional time (:time)
188
+ def build_event_attributes_hash(event_hash)
189
+ fail "No event name provided in #{event_hash}" unless event_hash[:event] || event_hash['event']
190
+
191
+ time = event_hash[:time] || event_hash['time']
192
+ time_hash = time ? { 'time' => time.strftime('%s') } : {}
193
+
194
+ event = extract_user_id_or_device_id_hash(event_hash).merge(time_hash).merge(
195
+ 'action' => 'track',
196
+ 'event' => event_hash[:event] || event_hash['event']
197
+ )
198
+ event_params = event_hash.reject { |k,v| k.to_s =~ /^(user_id|device_id|event|time)$/ }
199
+ if event_params.keys.size > 0
200
+ event.merge('params' => event_params )
201
+ else
202
+ event
203
+ end
204
+ end
205
+
206
+ # Leanplum does not support dates and times as of 2015-08-11
207
+ def turn_date_and_time_values_to_strings(hash)
208
+ new_hash = {}
209
+ hash.each do |k,v|
210
+ if v.is_a?(Time) || v.is_a?(DateTime)
211
+ new_hash[k] = v.strftime('%Y-%m-%d %H:%M:%S')
212
+ elsif v.is_a?(Date)
213
+ new_hash[k] = v.strftime('%Y-%m-%d')
214
+ else
215
+ new_hash[k] = v
216
+ end
217
+ end
218
+ new_hash
219
+ end
220
+
221
+ # In case leanplum decides your events are too old, they will send a warning.
222
+ # Right now we aren't responding to this directly.
223
+ # '{"response":[{"success":true,"warning":{"message":"Anomaly detected: time skew. User will be excluded from analytics."}}]}'
224
+ def validate_response(input, response)
225
+ success_indicators = response.body['response']
226
+ if success_indicators.size != input.size
227
+ fail "Attempted to update #{input.size} records but only received confirmation for #{success_indicators.size}!"
228
+ end
229
+
230
+ failure_indices = []
231
+ success_indicators.each_with_index do |s,i|
232
+ if s['success'].to_s != 'true'
233
+ @logger.error("Unsuccessful attempt to update at position #{i}: #{input[i]}")
234
+ failure_indices << i
235
+ else
236
+ @logger.debug("Successfully updated position #{i}: #{input[i]}")
237
+ end
238
+ end
239
+
240
+ fail LeanplumValidationException.new('Failed to update') if failure_indices.size > 0
241
+ end
242
+
243
+ def arrayify(x)
244
+ if x && !x.is_a?(Array)
245
+ [x]
246
+ else
247
+ x || []
248
+ end
249
+ end
250
+ end
251
+ end