leanplum_api 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ 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