├── .babelrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json └── src ├── MobileAnalyticsClient.js ├── MobileAnalyticsSession.js ├── MobileAnalyticsSessionManager.js ├── MobileAnalyticsUtilities.js ├── Storage.js ├── StorageKeys.js └── __tests__ ├── MobileAnalyticsSession.test.js ├── MobileAnalyticsUtilities.test.js └── Storage.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-native"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Xcode 6 | # 7 | build/ 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata 17 | *.xccheckout 18 | *.moved-aside 19 | DerivedData 20 | *.hmap 21 | *.ipa 22 | *.xcuserstate 23 | project.xcworkspace 24 | 25 | # Android/IntelliJ 26 | # 27 | build/ 28 | .idea 29 | .gradle 30 | local.properties 31 | *.iml 32 | 33 | # node.js 34 | # 35 | node_modules/ 36 | npm-debug.log 37 | yarn-error.log 38 | 39 | # BUCK 40 | buck-out/ 41 | \.buckd/ 42 | *.keystore 43 | 44 | # fastlane 45 | # 46 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 47 | # screenshots whenever they are needed. 48 | # For more information about the recommended setup visit: 49 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 50 | 51 | fastlane/report.xml 52 | fastlane/Preview.html 53 | fastlane/screenshots 54 | 55 | /.idea 56 | node_modules/* 57 | 58 | /android/app/innmensa.keystore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 innFactory 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-aws-mobile-analytics 2 | 3 | [![Build Status](https://travis-ci.org/innFactory/react-native-aws-mobile-analytics.svg?branch=master)](https://www.npmjs.com/package/react-native-aws-mobile-analytics) 4 | [![Version](https://img.shields.io/npm/v/react-native-aws-mobile-analytics.svg)](https://www.npmjs.com/package/react-native-aws-mobile-analytics) 5 | [![Downloads](https://img.shields.io/npm/dt/react-native-aws-mobile-analytics.svg)](https://www.npmjs.com/package/react-native-aws-mobile-analytics) 6 | 7 | A react-native module for using Amazon's AWS Mobile Analytics with the aws-sdk 8 | 9 | Highly inspirated by the javascript version [aws-sdk-mobile-analytics-js](https://github.com/aws/aws-sdk-mobile-analytics-js) 10 | 11 |
12 | 13 | ## Usage 14 | Add react-native-aws-mobile-analytics 15 | ``` 16 | npm install --save react-native-aws-mobile-analytics 17 | ``` 18 | 19 | Add Permission for Network State to your `AndroidManifest.xml` 20 | ``` 21 | 22 | ``` 23 | 24 | 25 | Add aws-sdk 26 | ``` 27 | npm install --save aws-sdk 28 | ``` 29 | 30 | react-native-aws-mobile-analytics needs the [react-native-device-info](https://github.com/rebeccahughes/react-native-device-info) package as dependency for an unique client id. Make sure it is correct linked. You may have to call react-native link: 31 | ``` 32 | react-native link 33 | ``` 34 | 35 |
36 | 37 | Create file `MobileAnalytics.js` where you can do the configuration: 38 | ```javascript 39 | import AWS from "aws-sdk/dist/aws-sdk-react-native"; 40 | import AMA from "react-native-aws-mobile-analytics"; 41 | import { 42 | Platform, 43 | } from 'react-native'; 44 | 45 | AWS.config.region = 'us-east-1'; 46 | AWS.config.credentials = new AWS.CognitoIdentityCredentials({ 47 | region: 'us-east-1', 48 | IdentityPoolId: 'us-east-1:4c6d17ff-9eb1-4805-914d-8db8536ab130', 49 | }); 50 | 51 | 52 | let appId_PROD = "2e9axxxxxxx742c5a35axxxxxxxxx2f"; 53 | let appId_DEV = "xxxx44be23c4xxx9xxxxxxxxxxxxx3fb"; 54 | 55 | let options = { 56 | appId: __DEV__?appId_DEV:appId_PROD, 57 | platform : Platform.OS === 'ios' ? 'iPhoneOS' : 'Android', 58 | //logger: console // comment in for debugging 59 | }; 60 | 61 | const MobileAnalyticsClient = new AMA.Manager(options); 62 | export default MobileAnalyticsClient; 63 | ``` 64 | 65 |
66 | 67 | Before you could send the first event you have to call `MobileAnalyticsClient.initialize(callback)` and wait for the callback. You can handle that in your root component like following: 68 | ```javascript 69 | import React from "react"; 70 | import MobileAnalyticsClient from "./MobileAnalytics"; 71 | import SomeComponent from "./components/SomeComponent"; 72 | 73 | export default class App extends React.Component { 74 | constructor(){ 75 | super(); 76 | 77 | this.state = { 78 | isMobileAnalyticsLoading: true, 79 | }; 80 | 81 | MobileAnalyticsClient.initialize(()=>this.setState({isMobileAnalyticsLoading: false})); 82 | } 83 | render() { 84 | if(this.state.isMobileAnalyticsLoading){ 85 | return null; 86 | } 87 | return ( 88 | 89 | ); 90 | } 91 | }; 92 | ``` 93 | 94 |
95 | 96 | Now you are able to send event in all your components: 97 | ```javascript 98 | import MobileAnalyticsClient from "../MobileAnalytics"; 99 | 100 | 101 | export default class SomeComponent extends Component { 102 | constructor() { 103 | super(); 104 | // send a custom event 105 | MobileAnalyticsClient.recordEvent('analyticsDemo', { 'attribute_1': 'main', 'attribute_2': 'page' }, { 'metric_1': 1 }); 106 | } 107 | } 108 | ``` 109 | 110 |
111 | 112 | #### Checkout the full example: 113 | [react-native-aws-mobile-analytics-demo](https://github.com/innFactory/react-native-aws-mobile-analytics-demo) 114 | 115 |
116 | 117 | ## Contributors 118 | 119 | [Anton Spöck](https://github.com/spoeck) 120 | 121 | Powered by [innFactory](https://innfactory.de/) 122 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module AMA 3 | * @description The global namespace for Amazon Mobile Analytics 4 | * @see AMA.Manager 5 | * @see AMA.Client 6 | * @see AMA.Session 7 | */ 8 | import Client from "./src/MobileAnalyticsClient"; 9 | import Util from "./src/MobileAnalyticsUtilities"; 10 | import StorageKeys from "./src/StorageKeys"; 11 | import Storage from "./src/Storage"; 12 | import Session from "./src/MobileAnalyticsSession"; 13 | import Manager from "./src/MobileAnalyticsSessionManager"; 14 | AMA = { 15 | Client, 16 | Util, 17 | StorageKeys, 18 | Storage, 19 | Session, 20 | Manager 21 | } 22 | export default AMA; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-aws-mobile-analytics", 3 | "version": "0.1.2", 4 | "description": "A react-native module for using Amazon's AWS Mobile Analytics with the aws-sdk", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/innFactory/react-native-aws-mobile-analytics" 9 | }, 10 | "dependencies": { 11 | "aws-sdk": "^2.71.0", 12 | "react-native-device-info": "^0.10.2" 13 | }, 14 | "devDependencies": { 15 | "babel-jest": "^19.0.0", 16 | "babel-preset-react-native": "1.9.1", 17 | "jest": "^20.0.4", 18 | "react": "16.0.0-alpha.6", 19 | "react-native": "^0.43.0", 20 | "react-test-renderer": "16.0.0-alpha.6", 21 | "regenerator-runtime": "^0.10.5" 22 | }, 23 | "scripts": { 24 | "test": "jest" 25 | }, 26 | "jest": { 27 | "preset": "react-native", 28 | "globals": { 29 | "__DEV__": true 30 | } 31 | }, 32 | "keywords": [ 33 | "react-native", 34 | "aws", 35 | "mobile", 36 | "analytics", 37 | "ama", 38 | "mobile-analytics", 39 | "amazon" 40 | ], 41 | "author": "Anton Spöck (https://github.com/spoeck)", 42 | "license": "MIT" 43 | } 44 | -------------------------------------------------------------------------------- /src/MobileAnalyticsClient.js: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk/dist/aws-sdk-react-native'; 2 | import Storage from './Storage'; 3 | import StorageKeys from './StorageKeys.js'; 4 | import Util from'./MobileAnalyticsUtilities'; 5 | const DeviceInfo = require('react-native-device-info'); 6 | 7 | /** 8 | * @typedef AMA.Client.Options 9 | * @property {string} appId - The Application ID from the Amazon Mobile Analytics Console 10 | * @property {string} [apiVersion=2014-06-05] - The version of the Mobile Analytics API to submit to. 11 | * @property {object} [provider=AWS.config.credentials] - Credentials to use for submitting events. 12 | * **Never check in credentials to source 13 | * control. 14 | * @property {boolean} [autoSubmitEvents=true] - Automatically Submit Events, Default: true 15 | * @property {number} [autoSubmitInterval=10000] - Interval to try to submit events in ms, 16 | * Default: 10s 17 | * @property {number} [batchSizeLimit=256000] - Batch Size in Bytes, Default: 256Kb 18 | * @property {AMA.Client.SubmitCallback} [submitCallback=] - Callback function that is executed when events are 19 | * successfully submitted 20 | * @property {AMA.Client.Attributes} [globalAttributes=] - Attribute to be applied to every event, may be 21 | * overwritten with a different value when recording events. 22 | * @property {AMA.Client.Metrics} [globalMetrics=] - Metric to be applied to every event, may be overwritten 23 | * with a different value when recording events. 24 | * @property {string} [clientId=GUID()] - A unique identifier representing this installation instance 25 | * of your app. This will be managed and persisted by the SDK 26 | * by default. 27 | * @property {string} [appTitle=] - The title of your app. For example, My App. 28 | * @property {string} [appVersionName=] - The version of your app. For example, V2.0. 29 | * @property {string} [appVersionCode=] - The version code for your app. For example, 3. 30 | * @property {string} [appPackageName=] - The name of your package. For example, com.example.my_app. 31 | * @property {string} [platform=] - The operating system of the device. For example, iPhoneOS. 32 | * @property {string} [plaformVersion=] - The version of the operating system of the device. 33 | * For example, 4.0.4. 34 | * @property {string} [model=] - The model of the device. For example, Nexus. 35 | * @property {string} [make=] - The manufacturer of the device. For example, Samsung. 36 | * @property {string} [locale=] - The locale of the device. For example, en_US. 37 | * @property {AMA.Client.Logger} [logger=] - Object of logger functions 38 | * @property {AMA.Storage} [storage=] - Storage client to persist events, will create a new AMA.Storage if not provided 39 | * @property {Object} [clientOptions=] - Low level client options to be passed to the AWS.MobileAnalytics low level SDK 40 | */ 41 | 42 | /** 43 | * @typedef AMA.Client.Logger 44 | * @description Uses Javascript Style log levels, one function for each level. Basic usage is to pass the console object 45 | * which will output directly to browser developer console. 46 | * @property {Function} [log=] - Logger for client log level messages 47 | * @property {Function} [info=] - Logger for interaction level messages 48 | * @property {Function} [warn=] - Logger for warn level messages 49 | * @property {Function} [error=] - Logger for error level messages 50 | */ 51 | /** 52 | * @typedef AMA.Client.Attributes 53 | * @type {object} 54 | * @description A collection of key-value pairs that give additional context to the event. The key-value pairs are 55 | * specified by the developer. 56 | */ 57 | /** 58 | * @typedef AMA.Client.Metrics 59 | * @type {object} 60 | * @description A collection of key-value pairs that gives additional measurable context to the event. The pairs 61 | * specified by the developer. 62 | */ 63 | /** 64 | * @callback AMA.Client.SubmitCallback 65 | * @param {Error} err 66 | * @param {Null} data 67 | * @param {string} batchId 68 | */ 69 | /** 70 | * @typedef AMA.Client.Event 71 | * @type {object} 72 | * @description A JSON object representing an event occurrence in your app and consists of the following: 73 | * @property {string} eventType - A name signifying an event that occurred in your app. This is used for grouping and 74 | * aggregating like events together for reporting purposes. 75 | * @property {string} timestamp - The time the event occurred in ISO 8601 standard date time format. 76 | * For example, 2014-06-30T19:07:47.885Z 77 | * @property {AMA.Client.Attributes} [attributes=] - A collection of key-value pairs that give additional context to 78 | * the event. The key-value pairs are specified by the developer. 79 | * This collection can be empty or the attribute object can be omitted. 80 | * @property {AMA.Client.Metrics} [metrics=] - A collection of key-value pairs that gives additional measurable context 81 | * to the event. The pairs specified by the developer. 82 | * @property {AMA.Session} session - Describes the session. Session information is required on ALL events. 83 | */ 84 | /** 85 | * @name AMA.Client 86 | * @namespace AMA.Client 87 | * @constructor 88 | * @param {AMA.Client.Options} options - A configuration map for the AMA.Client 89 | * @returns A new instance of the Mobile Analytics Mid Level Client 90 | */ 91 | export default class MobileAnalyticsClient { 92 | 93 | constructor(options, callback) { 94 | 95 | this.options = options || {}; 96 | this.options.logger = this.options.logger || {}; 97 | this.logger = { 98 | log : this.options.logger.log || Util.NOP, 99 | info : this.options.logger.info || Util.NOP, 100 | warn : this.options.logger.warn || Util.NOP, 101 | error: this.options.logger.error || Util.NOP 102 | }; 103 | this.logger.log = this.logger.log.bind(this.options.logger); 104 | this.logger.info = this.logger.info.bind(this.options.logger); 105 | this.logger.warn = this.logger.warn.bind(this.options.logger); 106 | this.logger.error = this.logger.error.bind(this.options.logger); 107 | 108 | this.logger.log('[Function:(AMA)Client Constructor]' + 109 | (options ? '\noptions:' + JSON.stringify(options) : '')); 110 | 111 | this.initOptions(options); 112 | 113 | this.storage = this.options.storage || new Storage(options.appId); 114 | this.storage.reload(()=>{ 115 | this.initStorage(callback); 116 | }); 117 | 118 | 119 | } 120 | 121 | initStorage(callback) { 122 | this.storage.setLogger(this.logger); 123 | 124 | this.storage.set( 125 | StorageKeys.GLOBAL_ATTRIBUTES, 126 | Util.mergeObjects(this.options.globalAttributes, 127 | this.storage.get(StorageKeys.GLOBAL_ATTRIBUTES) || {}) 128 | ); 129 | this.storage.set( 130 | StorageKeys.GLOBAL_METRICS, 131 | Util.mergeObjects(this.options.globalMetrics, 132 | this.storage.get(StorageKeys.GLOBAL_METRICS) || {}) 133 | ); 134 | 135 | this.storage.set(StorageKeys.CLIENT_ID, DeviceInfo.getUniqueID()); 136 | 137 | this.StorageKeys = { 138 | 'EVENTS' : 'AWSMobileAnalyticsEventStorage', 139 | 'BATCHES' : 'AWSMobileAnalyticsBatchStorage', 140 | 'BATCH_INDEX': 'AWSMobileAnalyticsBatchIndexStorage' 141 | }; 142 | 143 | this.outputs = {}; 144 | this.outputs.MobileAnalytics = new AWS.MobileAnalytics(this.options.clientOptions); 145 | this.outputs.timeoutReference = null; 146 | this.outputs.batchesInFlight = {}; 147 | 148 | this.outputs.events = this.storage.get(this.StorageKeys.EVENTS) || []; 149 | this.outputs.batches = this.storage.get(this.StorageKeys.BATCHES) || {}; 150 | this.outputs.batchIndex = this.storage.get(this.StorageKeys.BATCH_INDEX) || []; 151 | 152 | if (this.options.autoSubmitEvents) { 153 | this.submitEvents(); 154 | } 155 | 156 | callback(); 157 | } 158 | 159 | 160 | initOptions(options) { 161 | 162 | if (options.appId === undefined) { 163 | this.logger.error('AMA.Client must be initialized with an appId'); 164 | return null; //No need to run rest of init since appId is required 165 | } 166 | if (options.platform === undefined) { 167 | this.logger.error('AMA.Client must be initialized with a platform'); 168 | } 169 | 170 | this.options.apiVersion = this.options.apiVersion || '2014-06-05'; 171 | this.options.provider = this.options.provider || AWS.config.credentials; 172 | this.options.autoSubmitEvents = options.autoSubmitEvents !== false; 173 | this.options.autoSubmitInterval = this.options.autoSubmitInterval || 10000; 174 | this.options.batchSizeLimit = this.options.batchSizeLimit || 256000; 175 | this.options.submitCallback = this.options.submitCallback || Util.NOP; 176 | this.options.globalAttributes = this.options.globalAttributes || {}; 177 | this.options.globalMetrics = this.options.globalMetrics || {}; 178 | this.options.clientOptions = this.options.clientOptions || {}; 179 | this.options.clientOptions.provider = this.options.clientOptions.provider || this.options.provider; 180 | this.options.clientOptions.apiVersion = this.options.clientOptions.apiVersion || this.options.apiVersion; 181 | this.options.clientOptions.correctClockSkew = this.options.clientOptions.correctClockSkew !== false; 182 | this.options.clientOptions.retryDelayOptions = this.options.clientOptions.retryDelayOptions || {}; 183 | this.options.clientOptions.retryDelayOptions.base = this.options.clientOptions.retryDelayOptions.base || 3000; 184 | 185 | 186 | this.options.clientContext = this.options.clientContext || { 187 | 'client' : { 188 | 'client_id' : DeviceInfo.getUniqueID(), 189 | 'app_title' : this.options.appTitle, 190 | 'app_version_name': this.options.appVersionName, 191 | 'app_version_code': this.options.appVersionCode, 192 | 'app_package_name': this.options.appPackageName 193 | }, 194 | 'env' : { 195 | 'platform' : this.options.platform, 196 | 'platform_version': this.options.platformVersion, 197 | 'model' : this.options.model, 198 | 'make' : this.options.make, 199 | 'locale' : this.options.locale 200 | }, 201 | 'services': { 202 | 'mobile_analytics': { 203 | 'app_id' : this.options.appId, 204 | 'sdk_name' : 'aws-sdk-mobile-analytics-js', 205 | 'sdk_version': '0.9.2' + ':' + AWS.VERSION 206 | } 207 | }, 208 | 'custom' : {} 209 | }; 210 | } 211 | 212 | 213 | validateEvent(event) { 214 | let invalidMetrics = []; 215 | 216 | function customNameErrorFilter(name) { 217 | if (name.length === 0) { 218 | return true; 219 | } 220 | return name.length > 50; 221 | } 222 | 223 | function customAttrValueErrorFilter(name) { 224 | return event.attributes[name] && event.attributes[name].length > 200; 225 | } 226 | 227 | function validationError(errorMsg) { 228 | this.logger.error(errorMsg); 229 | return null; 230 | } 231 | 232 | invalidMetrics = Object.keys(event.metrics).filter(function (metricName) { 233 | return typeof event.metrics[metricName] !== 'number'; 234 | }); 235 | if (event.version !== 'v2.0') { 236 | return validationError('Event must have version v2.0'); 237 | } 238 | if (typeof event.eventType !== 'string') { 239 | return validationError('Event Type must be a string'); 240 | } 241 | if (invalidMetrics.length > 0) { 242 | return validationError('Event Metrics must be numeric (' + invalidMetrics[0] + ')'); 243 | } 244 | if (Object.keys(event.metrics).length + Object.keys(event.attributes).length > 40) { 245 | return validationError('Event Metric and Attribute Count cannot exceed 40'); 246 | } 247 | if (Object.keys(event.attributes).filter(customNameErrorFilter).length) { 248 | return validationError('Event Attribute names must be 1-50 characters'); 249 | } 250 | if (Object.keys(event.metrics).filter(customNameErrorFilter).length) { 251 | return validationError('Event Metric names must be 1-50 characters'); 252 | } 253 | if (Object.keys(event.attributes).filter(customAttrValueErrorFilter).length) { 254 | return validationError('Event Attribute values cannot be longer than 200 characters'); 255 | } 256 | return event; 257 | } 258 | 259 | 260 | /** 261 | * AMA.Client.createEvent 262 | * @param {string} eventType - Custom Event Type to be displayed in Console 263 | * @param {AMA.Session} session - Session Object (required for use within console) 264 | * @param {string} session.id - Identifier for current session 265 | * @param {string} session.startTimestamp - Timestamp that indicates the start of the session 266 | * @param [attributes=] - Custom attributes 267 | * @param [metrics=] - Custom metrics 268 | * @returns {AMA.Event} 269 | */ 270 | createEvent(eventType, session, attributes, metrics) { 271 | this.logger.log('[Function:(AMA.Client).createEvent]' + 272 | (eventType ? '\neventType:' + eventType : '') + 273 | (session ? '\nsession:' + session : '') + 274 | (attributes ? '\nattributes:' + JSON.stringify(attributes) : '') + 275 | (metrics ? '\nmetrics:' + JSON.stringify(metrics) : '')); 276 | attributes = attributes || {}; 277 | metrics = metrics || {}; 278 | 279 | Util.mergeObjects(attributes, this.options.globalAttributes); 280 | Util.mergeObjects(metrics, this.options.globalMetrics); 281 | 282 | Object.keys(attributes).forEach(function (name) { 283 | if (typeof attributes[name] !== 'string') { 284 | try { 285 | attributes[name] = JSON.stringify(attributes[name]); 286 | } catch (e) { 287 | this.logger.warn('Error parsing attribute ' + name); 288 | } 289 | } 290 | }); 291 | let event = { 292 | eventType : eventType, 293 | timestamp : new Date().toISOString(), 294 | session : { 295 | id : session.id, 296 | startTimestamp: session.startTimestamp 297 | }, 298 | version : 'v2.0', 299 | attributes: attributes, 300 | metrics : metrics 301 | }; 302 | if (session.stopTimestamp) { 303 | event.session.stopTimestamp = session.stopTimestamp; 304 | event.session.duration = new Date(event.stopTimestamp).getTime() - new Date(event.startTimestamp).getTime(); 305 | } 306 | return this.validateEvent(event); 307 | } 308 | 309 | 310 | /** 311 | * AMA.Client.pushEvent 312 | * @param {AMA.Event} event - event to be pushed onto queue 313 | * @returns {int} Index of event in outputs.events 314 | */ 315 | pushEvent(event) { 316 | if (!event) { 317 | return -1; 318 | } 319 | this.logger.log('[Function:(AMA.Client).pushEvent]' + 320 | (event ? '\nevent:' + JSON.stringify(event) : '')); 321 | //Push adds to the end of array and returns the size of the array 322 | let eventIndex = this.outputs.events.push(event); 323 | this.storage.set(this.StorageKeys.EVENTS, this.outputs.events); 324 | return (eventIndex - 1); 325 | } 326 | 327 | /** 328 | * Helper to record events, will automatically submit if the events exceed batchSizeLimit 329 | * @param {string} eventType - Custom event type name 330 | * @param {AMA.Session} session - Session object 331 | * @param {AMA.Client.Attributes} [attributes=] - Custom attributes 332 | * @param {AMA.Client.Metrics} [metrics=] - Custom metrics 333 | * @returns {AMA.Event} The event that was recorded 334 | */ 335 | recordEvent(eventType, session, attributes, metrics) { 336 | this.logger.log('[Function:(AMA.Client).recordEvent]' + 337 | (eventType ? '\neventType:' + eventType : '') + 338 | (session ? '\nsession:' + session : '') + 339 | (attributes ? '\nattributes:' + JSON.stringify(attributes) : '') + 340 | (metrics ? '\nmetrics:' + JSON.stringify(metrics) : '')); 341 | let index, event = this.createEvent(eventType, session, attributes, metrics); 342 | if (event) { 343 | index = this.pushEvent(event); 344 | if (Util.getRequestBodySize(this.outputs.events) >= this.options.batchSizeLimit) { 345 | this.submitEvents(); 346 | } 347 | return this.outputs.events[index]; 348 | } 349 | return null; 350 | } 351 | 352 | 353 | /** 354 | * recordMonetizationEvent 355 | * @param session 356 | * @param {Object} monetizationDetails - Details about Monetization Event 357 | * @param {string} monetizationDetails.currency - ISO Currency of event 358 | * @param {string} monetizationDetails.productId - Product Id of monetization event 359 | * @param {number} monetizationDetails.quantity - Quantity of product in transaction 360 | * @param {string|number} monetizationDetails.price - Price of product either ISO formatted string, or number 361 | * with associated ISO Currency 362 | * @param {AMA.Client.Attributes} [attributes=] - Custom attributes 363 | * @param {AMA.Client.Metrics} [metrics=] - Custom metrics 364 | * @returns {event} The event that was recorded 365 | */ 366 | recordMonetizationEvent(session, monetizationDetails, attributes, metrics) { 367 | this.logger.log('[Function:(AMA.Client).recordMonetizationEvent]' + 368 | (session ? '\nsession:' + session : '') + 369 | (monetizationDetails ? '\nmonetizationDetails:' + JSON.stringify(monetizationDetails) : '') + 370 | (attributes ? '\nattributes:' + JSON.stringify(attributes) : '') + 371 | (metrics ? '\nmetrics:' + JSON.stringify(metrics) : '')); 372 | 373 | attributes = attributes || {}; 374 | metrics = metrics || {}; 375 | attributes._currency = monetizationDetails.currency || attributes._currency; 376 | attributes._product_id = monetizationDetails.productId || attributes._product_id; 377 | metrics._quantity = monetizationDetails.quantity || metrics._quantity; 378 | if (typeof monetizationDetails.price === 'number') { 379 | metrics._item_price = monetizationDetails.price || metrics._item_price; 380 | } else { 381 | attributes._item_price_formatted = monetizationDetails.price || attributes._item_price_formatted; 382 | } 383 | return this.recordEvent('_monetization.purchase', session, attributes, metrics); 384 | } 385 | 386 | 387 | /** 388 | * submitEvents 389 | * @param {Object} [options=] - options for submitting events 390 | * @param {Object} [options.clientContext=this.options.clientContext] - clientContext to submit with defaults 391 | * to options.clientContext 392 | * @param {SubmitCallback} [options.submitCallback=this.options.submitCallback] - Callback function that is executed 393 | * when events are successfully 394 | * submitted 395 | * @returns {Array} Array of batch indices that were submitted 396 | */ 397 | submitEvents(options) { 398 | options = options || {}; 399 | options.submitCallback = options.submitCallback || this.options.submitCallback; 400 | this.logger.log('[Function:(AMA.Client).submitEvents]' + 401 | (options ? '\noptions:' + JSON.stringify(options) : '')); 402 | 403 | 404 | if (this.options.autoSubmitEvents) { 405 | clearTimeout(this.outputs.timeoutReference); 406 | this.outputs.timeoutReference = setTimeout(this.submitEvents.bind(this), this.options.autoSubmitInterval); 407 | } 408 | let warnMessage; 409 | //Get distribution of retries across clients by introducing a weighted rand. 410 | //Probability will increase over time to an upper limit of 60s 411 | if (this.outputs.isThrottled && this.throttlingSuppressionFunction() < Math.random()) { 412 | warnMessage = 'Prevented submission while throttled'; 413 | } else if (Object.keys(this.outputs.batchesInFlight).length > 0) { 414 | warnMessage = 'Prevented submission while batches are in flight'; 415 | } else if (this.outputs.batches.length === 0 && this.outputs.events.length === 0) { 416 | warnMessage = 'No batches or events to be submitted'; 417 | } else if (this.outputs.lastSubmitTimestamp && Util.timestamp() - this.outputs.lastSubmitTimestamp < 1000) { 418 | warnMessage = 'Prevented multiple submissions in under a second'; 419 | } 420 | if (warnMessage) { 421 | this.logger.warn(warnMessage); 422 | return []; 423 | } 424 | this.generateBatches(); 425 | 426 | this.outputs.lastSubmitTimestamp = Util.timestamp(); 427 | if (this.outputs.isThrottled) { 428 | //Only submit the first batch if throttled 429 | this.logger.warn('Is throttled submitting first batch'); 430 | options.batchId = this.outputs.batchIndex[0]; 431 | return [this.submitBatchById(options)]; 432 | } 433 | 434 | return this.submitAllBatches(options); 435 | } 436 | 437 | throttlingSuppressionFunction(timestamp) { 438 | timestamp = timestamp || Util.timestamp(); 439 | return Math.pow(timestamp - this.outputs.lastSubmitTimestamp, 2) / Math.pow(60000, 2); 440 | } 441 | 442 | 443 | generateBatches() { 444 | while (this.outputs.events.length > 0) { 445 | let lastIndex = this.outputs.events.length; 446 | this.logger.log(this.outputs.events.length + ' events to be submitted'); 447 | while (lastIndex > 1 && 448 | Util.getRequestBodySize(this.outputs.events.slice(0, lastIndex)) > this.options.batchSizeLimit) { 449 | this.logger.log('Finding Batch Size (' + this.options.batchSizeLimit + '): ' + lastIndex + '(' + 450 | Util.getRequestBodySize(this.outputs.events.slice(0, lastIndex)) + ')'); 451 | lastIndex -= 1; 452 | } 453 | if (this.persistBatch(this.outputs.events.slice(0, lastIndex))) { 454 | //Clear event queue 455 | this.outputs.events.splice(0, lastIndex); 456 | this.storage.set(this.StorageKeys.EVENTS, this.outputs.events); 457 | } 458 | } 459 | } 460 | 461 | 462 | persistBatch(eventBatch) { 463 | this.logger.log(eventBatch.length + ' events in batch'); 464 | if (Util.getRequestBodySize(eventBatch) < 512000) { 465 | let batchId = Util.GUID(); 466 | //Save batch so data is not lost. 467 | this.outputs.batches[batchId] = eventBatch; 468 | this.storage.set(this.StorageKeys.BATCHES, this.outputs.batches); 469 | this.outputs.batchIndex.push(batchId); 470 | this.storage.set(this.StorageKeys.BATCH_INDEX, this.outputs.batchIndex); 471 | return true; 472 | } 473 | this.logger.error('Events too large'); 474 | return false; 475 | } 476 | 477 | submitAllBatches(options) { 478 | options.submitCallback = options.submitCallback || this.options.submitCallback; 479 | this.logger.log('[Function:(AMA.Client).submitAllBatches]' + 480 | (options ? '\noptions:' + JSON.stringify(options) : '')); 481 | let indices = [], 482 | that = this; 483 | this.outputs.batchIndex.forEach(function (batchIndex) { 484 | options.batchId = batchIndex; 485 | options.clientContext = options.clientContext || that.options.clientContext; 486 | if (!that.outputs.batchesInFlight[batchIndex]) { 487 | indices.push(that.submitBatchById(options)); 488 | } 489 | }); 490 | return indices; 491 | } 492 | 493 | 494 | submitBatchById(options) { 495 | if (typeof(options) !== 'object' || !options.batchId) { 496 | this.logger.error('Invalid Options passed to submitBatchById'); 497 | return; 498 | } 499 | options.submitCallback = options.submitCallback || this.options.submitCallback; 500 | this.logger.log('[Function:(AMA.Client).submitBatchById]' + 501 | (options ? '\noptions:' + JSON.stringify(options) : '')); 502 | let eventBatch = { 503 | 'events' : this.outputs.batches[options.batchId], 504 | 'clientContext': JSON.stringify(options.clientContext || this.options.clientContext) 505 | }; 506 | this.outputs.batchesInFlight[options.batchId] = Util.timestamp(); 507 | this.outputs.MobileAnalytics.putEvents(eventBatch, 508 | this.handlePutEventsResponse(options.batchId, options.submitCallback)); 509 | return options.batchId; 510 | } 511 | 512 | 513 | handlePutEventsResponse(batchId, callback) { 514 | const NON_RETRYABLE_EXCEPTIONS = ['BadRequestException', 'SerializationException', 'ValidationException']; 515 | let self = this; 516 | return function (err, data) { 517 | let clearBatch = true, 518 | wasThrottled = self.outputs.isThrottled; 519 | if (err) { 520 | self.logger.error(err, data); 521 | if (err.statusCode === undefined || err.statusCode === 400) { 522 | if (NON_RETRYABLE_EXCEPTIONS.indexOf(err.code) < 0) { 523 | clearBatch = false; 524 | } 525 | self.outputs.isThrottled = err.code === 'ThrottlingException'; 526 | if (self.outputs.isThrottled) { 527 | self.logger.warn('Application is currently throttled'); 528 | } 529 | } 530 | } else { 531 | self.logger.info('Events Submitted Successfully'); 532 | self.outputs.isThrottled = false; 533 | } 534 | if (clearBatch) { 535 | self.clearBatchById(batchId); 536 | } 537 | delete self.outputs.batchesInFlight[batchId]; 538 | callback(err, data, batchId); 539 | if (wasThrottled && !self.outputs.isThrottled) { 540 | self.logger.warn('Was throttled flushing remaining batches', callback); 541 | self.submitAllBatches({ 542 | submitCallback: callback 543 | }); 544 | } 545 | }; 546 | } 547 | 548 | 549 | clearBatchById(batchId) { 550 | this.logger.log('[Function:(AMA.Client).clearBatchById]' + 551 | (batchId ? '\nbatchId:' + batchId : '')); 552 | if (this.outputs.batchIndex.indexOf(batchId) !== -1) { 553 | delete this.outputs.batches[batchId]; 554 | this.outputs.batchIndex.splice(this.outputs.batchIndex.indexOf(batchId), 1); 555 | 556 | // Persist latest batches / events 557 | this.storage.set(this.StorageKeys.BATCH_INDEX, this.outputs.batchIndex); 558 | this.storage.set(this.StorageKeys.BATCHES, this.outputs.batches); 559 | } 560 | } 561 | 562 | 563 | 564 | } -------------------------------------------------------------------------------- /src/MobileAnalyticsSession.js: -------------------------------------------------------------------------------- 1 | import StorageKeys from "./StorageKeys.js"; 2 | import Util from "./MobileAnalyticsUtilities"; 3 | 4 | 5 | /** 6 | * @name AMA.Session 7 | * @namespace AMA.Session 8 | * @constructor 9 | * @param {Object=} [options=] - A configuration map for the Session 10 | * @param {string=} [options.sessionId=Utilities.GUID()]- A sessionId for session. 11 | * @param {string=} [options.appId=new Date().toISOString()] - The start Timestamp (default now). 12 | * @param {number=} [options.sessionLength=600000] - Length of session in Milliseconds (default 10 minutes). 13 | * @param {AMA.Session.ExpirationCallback=} [options.expirationCallback] - Callback Function for when a session expires 14 | * @param {AMA.Client.Logger=} [options.logger=] - Object containing javascript style logger functions (passing console 15 | * will output to browser dev consoles) 16 | */ 17 | /** 18 | * @callback AMA.Session.ExpirationCallback 19 | * @param {AMA.Session} session 20 | * @returns {boolean|int} - Returns either true to extend the session by the sessionLength or an int with the number of 21 | * seconds to extend the session. All other values will clear the session from storage. 22 | */ 23 | export default class MobileAnalyticsSession { 24 | 25 | constructor(options) { 26 | 27 | this.options = options || {}; 28 | this.options.logger = this.options.logger || {}; 29 | this.logger = { 30 | log: this.options.logger.log || Util.NOP, 31 | info: this.options.logger.info || Util.NOP, 32 | warn: this.options.logger.warn || Util.NOP, 33 | error: this.options.logger.error || Util.NOP 34 | }; 35 | this.logger.log = this.logger.log.bind(this.options.logger); 36 | this.logger.info = this.logger.info.bind(this.options.logger); 37 | this.logger.warn = this.logger.warn.bind(this.options.logger); 38 | this.logger.error = this.logger.error.bind(this.options.logger); 39 | this.logger.log('[Function:(AWS.MobileAnalyticsClient)Session Constructor]' + 40 | (options ? '\noptions:' + JSON.stringify(options) : '')); 41 | this.options.expirationCallback = this.options.expirationCallback || Util.NOP; 42 | this.id = this.options.sessionId || Util.GUID(); 43 | this.sessionLength = this.options.sessionLength || 600000; //Default session length is 10 minutes 44 | //Suffix the AMA.Storage Keys with Session Id to ensure proper scope 45 | this.StorageKeys = { 46 | 'SESSION_ID': StorageKeys.SESSION_ID + this.id, 47 | 'SESSION_EXPIRATION': StorageKeys.SESSION_EXPIRATION + this.id, 48 | 'SESSION_START_TIMESTAMP': StorageKeys.SESSION_START_TIMESTAMP + this.id 49 | }; 50 | this.startTimestamp = this.options.startTime || 51 | this.options.storage.get(this.StorageKeys.SESSION_START_TIMESTAMP) || 52 | new Date().toISOString(); 53 | this.expirationDate = parseInt(this.options.storage.get(this.StorageKeys.SESSION_EXPIRATION), 10); 54 | if (isNaN(this.expirationDate)) { 55 | this.expirationDate = (new Date().getTime() + this.sessionLength); 56 | } 57 | this.options.storage.set(this.StorageKeys.SESSION_ID, this.id); 58 | this.options.storage.set(this.StorageKeys.SESSION_EXPIRATION, this.expirationDate); 59 | this.options.storage.set(this.StorageKeys.SESSION_START_TIMESTAMP, this.startTimestamp); 60 | this.sessionTimeoutReference = setTimeout(this.expireSession.bind(this), this.sessionLength); 61 | } 62 | 63 | 64 | /** 65 | * Expire session and clear session 66 | * @param {expirationCallback=} Callback function to call when sessions expire 67 | */ 68 | expireSession(expirationCallback) { 69 | this.logger.log('[Function:(Session).expireSession]'); 70 | expirationCallback = expirationCallback || this.options.expirationCallback; 71 | let shouldExtend = expirationCallback(this); 72 | if (typeof shouldExtend === 'boolean' && shouldExtend) { 73 | shouldExtend = this.options.sessionLength; 74 | } 75 | if (typeof shouldExtend === 'number') { 76 | this.extendSession(shouldExtend); 77 | } else { 78 | this.clearSession(); 79 | } 80 | } 81 | 82 | /** 83 | * Clear session from storage system 84 | */ 85 | clearSession() { 86 | this.logger.log('[Function:(Session).clearSession]'); 87 | clearTimeout(this.sessionTimeoutReference); 88 | this.options.storage.delete(this.StorageKeys.SESSION_ID); 89 | this.options.storage.delete(this.StorageKeys.SESSION_EXPIRATION); 90 | this.options.storage.delete(this.StorageKeys.SESSION_START_TIMESTAMP); 91 | } 92 | 93 | /** 94 | * Extend session by adding to the expiration timestamp 95 | * @param {int} [sessionExtensionLength=sessionLength] - The number of milliseconds to add to the expiration date 96 | * (session length by default). 97 | */ 98 | extendSession(sessionExtensionLength) { 99 | this.logger.log('[Function:(Session).extendSession]' + 100 | (sessionExtensionLength ? '\nsessionExtensionLength:' + sessionExtensionLength : '')); 101 | sessionExtensionLength = sessionExtensionLength || this.sessionLength; 102 | this.setSessionTimeout(this.expirationDate + parseInt(sessionExtensionLength, 10)); 103 | } 104 | 105 | 106 | /** 107 | * @param {string} [stopDate=now] - The ISO Date String to set the stopTimestamp to (now for default). 108 | */ 109 | stopSession(stopDate) { 110 | this.logger.log('[Function:(Session).stopSession]' + (stopDate ? '\nstopDate:' + stopDate : '')); 111 | this.stopTimestamp = stopDate || new Date().toISOString(); 112 | } 113 | 114 | 115 | /** 116 | * Reset session timeout to expire in a given number of seconds 117 | * @param {int} [milliseconds=sessionLength] - The number of milliseconds until the session should expire (from now). 118 | */ 119 | resetSessionTimeout(milliseconds) { 120 | this.logger.log('[Function:(Session).resetSessionTimeout]' + 121 | (milliseconds ? '\nmilliseconds:' + milliseconds : '')); 122 | milliseconds = milliseconds || this.sessionLength; 123 | this.setSessionTimeout(new Date().getTime() + milliseconds); 124 | } 125 | 126 | /** 127 | * Setter for the session timeout 128 | * @param {int} timeout - epoch timestamp 129 | */ 130 | setSessionTimeout(timeout) { 131 | this.logger.log('[Function:(Session).setSessionTimeout]' + (timeout ? '\ntimeout:' + timeout : '')); 132 | clearTimeout(this.sessionTimeoutReference); 133 | this.expirationDate = timeout; 134 | this.options.storage.set(this.StorageKeys.SESSION_EXPIRATION, this.expirationDate); 135 | this.sessionTimeoutReference = setTimeout(this.expireSession.bind(this), 136 | this.expirationDate - (new Date()).getTime()); 137 | } 138 | 139 | } -------------------------------------------------------------------------------- /src/MobileAnalyticsSessionManager.js: -------------------------------------------------------------------------------- 1 | import Client from './MobileAnalyticsClient'; 2 | import Util from'./MobileAnalyticsUtilities'; 3 | import StorageKeys from './StorageKeys.js'; 4 | import Session from "./MobileAnalyticsSession"; 5 | import {NetInfo} from "react-native"; 6 | 7 | /** 8 | * @typedef AMA.Manager.Options 9 | * @augments AMA.Client.Options 10 | * @property {AMA.Session.ExpirationCallback} [expirationCallback=] - Callback function to call when sessions expire 11 | */ 12 | 13 | /** 14 | * @name AMA.Manager 15 | * @namespace AMA.Manager 16 | * @constructor 17 | * @param {AMA.Client.Options|AMA.Client} options - A configuration map for the AMA.Client or an instantiated AMA.Client 18 | * @see AMA.Client 19 | */ 20 | export default class Manager { 21 | 22 | 23 | constructor(options) { 24 | this.options = options; 25 | } 26 | 27 | async initialize(onSuccess, onConnectionFailure) { 28 | 29 | let isConnected = await NetInfo.isConnected.fetch(); 30 | 31 | if (isConnected) { 32 | let options = this.options; 33 | if (options instanceof Client) { 34 | this.client = options; 35 | this.initSession(onSuccess); 36 | } else { 37 | options._autoSubmitEvents = options.autoSubmitEvents; 38 | options.autoSubmitEvents = false; 39 | this.client = new Client(options, ()=>{ 40 | options.autoSubmitEvents = options._autoSubmitEvents !== false; 41 | delete options._autoSubmitEvents; 42 | this.initSession(onSuccess); 43 | }); 44 | } 45 | } else { 46 | console.log('[Function:(AMA.Manager).initialize: Not initializeable (no internet connection)]'); 47 | onConnectionFailure ? onConnectionFailure() : onSuccess(); 48 | } 49 | 50 | } 51 | 52 | initSession(callback) { 53 | 54 | this.options = this.client.options; 55 | this.outputs = this.client.outputs; 56 | this.options.expirationCallback = this.options.expirationCallback || Util.NOP; 57 | 58 | this.checkForStoredSessions(); 59 | if (!this.outputs.session) { 60 | this.startSession(); 61 | } 62 | if (this.options.autoSubmitEvents) { 63 | this.client.submitEvents(); 64 | } 65 | 66 | callback(); 67 | } 68 | 69 | 70 | checkForStoredSessions() { 71 | this.client.storage.each(function (key) { 72 | if (key.indexOf(StorageKeys.SESSION_ID) === 0) { 73 | this.outputs.session = new Session({ 74 | storage: this.client.storage, 75 | sessionId: this.client.storage.get(key), 76 | sessionLength: this.options.sessionLength, 77 | expirationCallback: function (session) { 78 | let shouldExtend = this.options.expirationCallback(session); 79 | if (shouldExtend === true || typeof shouldExtend === 'number') { 80 | return shouldExtend; 81 | } 82 | this.stopSession(); 83 | } 84 | }); 85 | if (new Date().getTime() > this.outputs.session.expirationDate) { 86 | this.outputs.session.expireSession(); 87 | delete this.outputs.session; 88 | } 89 | } 90 | }); 91 | } 92 | 93 | 94 | 95 | /** 96 | * submitEvents 97 | * @param {Object} [options=] - options for submitting events 98 | * @param {Object} [options.clientContext=this.options.clientContext] - clientContext to submit with defaults to 99 | * options.clientContext 100 | * @returns {Array} Array of batch indices that were submitted 101 | */ 102 | submitEvents(options) { 103 | return this.client.submitEvents(options); 104 | }; 105 | 106 | 107 | /** 108 | * Function to start a session 109 | * @returns {AMA.Client.Event} The start session event recorded 110 | */ 111 | startSession() { 112 | this.client.logger.log('[Function:(AMA.Manager).startSession]'); 113 | if (this.outputs.session) { 114 | //Clear Session 115 | this.outputs.session.clearSession(); 116 | } 117 | this.outputs.session = new Session({ 118 | storage: this.client.storage, 119 | logger: this.client.options.logger, 120 | sessionLength: this.options.sessionLength, 121 | expirationCallback: function (session) { 122 | let shouldExtend = this.options.expirationCallback(session); 123 | if (shouldExtend === true || typeof shouldExtend === 'number') { 124 | return shouldExtend; 125 | } 126 | this.stopSession(); 127 | }.bind(this) 128 | }); 129 | return this.recordEvent('_session.start'); 130 | }; 131 | 132 | 133 | 134 | /** 135 | * Function to extend the current session. 136 | * @param {int} [milliseconds=options.sessionLength] - Milliseconds to extend the session by, will default 137 | * to another session length 138 | * @returns {int} The Session expiration (in Milliseconds) 139 | */ 140 | extendSession(milliseconds) { 141 | return this.outputs.session.extendSession(milliseconds || this.options.sessionLength); 142 | }; 143 | 144 | 145 | /** 146 | * Function to stop the current session 147 | * @returns {AMA.Client.Event} The stop session event recorded 148 | */ 149 | stopSession() { 150 | this.client.logger.log('[Function:(AMA.Manager).stopSession]'); 151 | this.outputs.session.stopSession(); 152 | this.outputs.session.expireSession(Util.NOP); 153 | return this.recordEvent('_session.stop'); 154 | }; 155 | 156 | 157 | /** 158 | * Function to stop the current session and start a new one 159 | * @returns {AMA.Session} The new Session Object for the SessionManager 160 | */ 161 | renewSession() { 162 | this.stopSession(); 163 | this.startSession(); 164 | return this.outputs.session; 165 | }; 166 | 167 | 168 | /** 169 | * Function that constructs a Mobile Analytics Event 170 | * @param {string} eventType - Custom Event Type to be displayed in Console 171 | * @param {AMA.Client.Attributes} [attributes=] - Map of String attributes 172 | * @param {AMA.Client.Metrics} [metrics=] - Map of numeric values 173 | * @returns {AMA.Client.Event} 174 | */ 175 | createEvent(eventType, attributes, metrics) { 176 | return this.client.createEvent(eventType, this.outputs.session, attributes, metrics); 177 | }; 178 | 179 | 180 | /** 181 | * Function to record a custom event 182 | * @param eventType - Custom event type name 183 | * @param {AMA.Client.Attributes} [attributes=] - Custom attributes 184 | * @param {AMA.Client.Metrics} [metrics=] - Custom metrics 185 | * @returns {AMA.Client.Event} The event that was recorded 186 | */ 187 | recordEvent(eventType, attributes, metrics) { 188 | if (this.client != undefined) { 189 | return this.client.recordEvent(eventType, this.outputs.session, attributes, metrics); 190 | } else { 191 | console.log('[Function:(AMA.Manager).recordEvent: Client is not initialized]'); 192 | } 193 | }; 194 | 195 | 196 | /** 197 | * Function to record a monetization event 198 | * @param {Object} monetizationDetails - Details about Monetization Event 199 | * @param {string} monetizationDetails.currency - ISO Currency of event 200 | * @param {string} monetizationDetails.productId - Product Id of monetization event 201 | * @param {number} monetizationDetails.quantity - Quantity of product in transaction 202 | * @param {string|number} monetizationDetails.price - Price of product either ISO formatted string, or number 203 | * with associated ISO Currency 204 | * @param {AMA.Client.Attributes} [attributes=] - Custom attributes 205 | * @param {AMA.Client.Metrics} [metrics=] - Custom metrics 206 | * @returns {AMA.Client.Event} The event that was recorded 207 | */ 208 | recordMonetizationEvent(monetizationDetails, attributes, metrics) { 209 | if (this.client != undefined) { 210 | return this.client.recordMonetizationEvent(this.outputs.session, monetizationDetails, attributes, metrics); 211 | } else { 212 | console.log('[Function:(AMA.Manager).recordMonetizationEvent: Client is not initialized]'); 213 | } 214 | }; 215 | } 216 | -------------------------------------------------------------------------------- /src/MobileAnalyticsUtilities.js: -------------------------------------------------------------------------------- 1 | export default class MobileAnalyticsUtilities { 2 | 3 | static s4() { 4 | return Math.floor((1 + Math.random()) * 0x10000) 5 | .toString(16) 6 | .substring(1); 7 | } 8 | 9 | 10 | static utf8ByteLength(str) { 11 | if (typeof str !== 'string') { 12 | str = JSON.stringify(str); 13 | } 14 | let s = str.length, i, code; 15 | for (i = str.length - 1; i >= 0; i -= 1) { 16 | code = str.charCodeAt(i); 17 | if (code > 0x7f && code <= 0x7ff) { 18 | s += 1; 19 | } else if (code > 0x7ff && code <= 0xffff) { 20 | s += 2; 21 | } 22 | if (code >= 0xDC00 && code <= 0xDFFF) { /*trail surrogate*/ 23 | i -= 1; 24 | } 25 | } 26 | return s; 27 | } 28 | 29 | 30 | static GUID() { 31 | return this.s4() + this.s4() + '-' + this.s4() + '-' + this.s4() + 32 | '-' + this.s4() + '-' + this.s4() + this.s4() + this.s4(); 33 | } 34 | 35 | 36 | static mergeObjects(override, initial) { 37 | Object.keys(initial).forEach(function (key) { 38 | if (initial.hasOwnProperty(key)) { 39 | override[key] = override[key] || initial[key]; 40 | } 41 | }); 42 | return override; 43 | } 44 | 45 | 46 | static copy(original, extension) { 47 | return this.mergeObjects(JSON.parse(JSON.stringify(original)), extension || {}); 48 | } 49 | 50 | static NOP() { 51 | return undefined; 52 | } 53 | 54 | 55 | static timestamp() { 56 | return new Date().getTime(); 57 | } 58 | 59 | static getRequestBodySize(str) { 60 | return this.utf8ByteLength(str); 61 | } 62 | } -------------------------------------------------------------------------------- /src/Storage.js: -------------------------------------------------------------------------------- 1 | import AMA from '../index'; 2 | import Util from './MobileAnalyticsUtilities'; 3 | import { 4 | AsyncStorage 5 | } from "react-native"; 6 | 7 | 8 | export default class Storage { 9 | 10 | 11 | constructor(appId) { 12 | this.storageKey = 'AWSMobileAnalyticsStorage-' + appId; 13 | AMA[this.storageKey] = AMA[this.storageKey] || {}; 14 | this.cache = {}; 15 | this.cache.id = this.cache.id || Util.GUID(); 16 | 17 | 18 | this.logger = { 19 | log: Util.NOP, 20 | info: Util.NOP, 21 | warn: Util.NOP, 22 | error: Util.NOP 23 | }; 24 | } 25 | 26 | 27 | get(key) { 28 | return this.cache[key]; 29 | }; 30 | 31 | set(key, value) { 32 | this.cache[key] = value; 33 | return this.saveToNativeStorage(); 34 | }; 35 | 36 | delete(key) { 37 | delete this.cache[key]; 38 | this.saveToNativeStorage(); 39 | }; 40 | 41 | each(callback) { 42 | for (let key in this.cache) { 43 | if (this.cache.hasOwnProperty(key)) { 44 | callback(key, this.cache[key]); 45 | } 46 | } 47 | } 48 | 49 | saveToNativeStorage() { 50 | this.logger.log('[Function:(AWS.MobileAnalyticsClient.Storage).saveNativeStorage]'); 51 | AsyncStorage.setItem(this.storageKey, JSON.stringify(this.cache), e => { 52 | if (e) { 53 | this.logger.log("Error AMA Storage: " + e); 54 | } else { 55 | this.logger.log("AMA Storage Cache: " + JSON.stringify(this.cache)); 56 | } 57 | }); 58 | } 59 | 60 | 61 | reload(callback) { 62 | let storedCache; 63 | this.logger.log('[Function:(AWS.MobileAnalyticsClient.Storage).loadNativeStorage]'); 64 | storedCache = AsyncStorage.getItem(this.storageKey, (e, r) => { 65 | if (e) { 66 | this.logger.log("Error loading Native Storage: " + JSON.stringify(e)) 67 | } else { 68 | //Try to parse, if corrupt delete 69 | try { 70 | this.cache = JSON.parse(storedCache); 71 | callback(); 72 | } catch (parseJSONError) { 73 | //Corrupted stored cache, delete it 74 | this.clearNativeStorage(callback); 75 | } 76 | } 77 | }); 78 | } 79 | 80 | setLogger(logFunction) { 81 | this.logger = logFunction; 82 | } 83 | 84 | clearNativeStorage(callback) { 85 | this.cache = {}; 86 | this.logger.log('[Function:(AWS.MobileAnalyticsClient.Storage).clearNativeStorage]'); 87 | AsyncStorage.removeItem(this.storageKey); 88 | callback(); 89 | 90 | } 91 | 92 | 93 | } -------------------------------------------------------------------------------- /src/StorageKeys.js: -------------------------------------------------------------------------------- 1 | const StorageKeys = { 2 | 'CLIENT_ID': 'AWSMobileAnalyticsClientId', 3 | 'GLOBAL_ATTRIBUTES': 'AWSMobileAnalyticsGlobalAttributes', 4 | 'GLOBAL_METRICS': 'AWSMobileAnalyticsGlobalMetrics', 5 | 'SESSION_ID': 'MobileAnalyticsSessionId', 6 | 'SESSION_EXPIRATION': 'MobileAnalyticsSessionExpiration', 7 | 'SESSION_START_TIMESTAMP': 'MobileAnalyticsSessionStartTimeStamp' 8 | }; 9 | 10 | export default StorageKeys; 11 | -------------------------------------------------------------------------------- /src/__tests__/MobileAnalyticsSession.test.js: -------------------------------------------------------------------------------- 1 | import Session from "../MobileAnalyticsSession"; 2 | import Storage from "../Storage"; 3 | 4 | 5 | describe('Session', () => { 6 | describe('Initialize Session (Default Values)', () => { 7 | const storage = new Storage("1234"); 8 | const session = new Session({storage: storage}); 9 | 10 | test("should be initialized", () => { 11 | expect(session).toBeDefined(); 12 | }); 13 | 14 | 15 | test("should have scoped sessionId storage key", () => { 16 | expect(session.StorageKeys.SESSION_ID).not.toBe("MobileAnalyticsSessionId"); 17 | expect(session.StorageKeys.SESSION_ID).toEqual(expect.stringContaining("MobileAnalyticsSessionId")); 18 | expect(session.StorageKeys.SESSION_ID).toEqual(expect.stringContaining(session.id)); 19 | 20 | }); 21 | 22 | test("should have scoped sessionExpiration storage key", () => { 23 | expect(session.StorageKeys.SESSION_EXPIRATION).not.toEqual("MobileAnalyticsSessionExpiration"); 24 | expect(session.StorageKeys.SESSION_EXPIRATION).toEqual(expect.stringContaining("MobileAnalyticsSessionExpiration")); 25 | expect(session.StorageKeys.SESSION_EXPIRATION).toEqual(expect.stringContaining(session.id)); 26 | }); 27 | 28 | test("should persist session id", () => { 29 | expect(storage.get(session.StorageKeys.SESSION_ID)).toBeDefined(); 30 | expect(storage.get(session.StorageKeys.SESSION_ID)).toEqual(session.id); 31 | }); 32 | 33 | test("should persist expiration", () => { 34 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).toBeDefined(); 35 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).toEqual(session.expirationDate); 36 | }); 37 | 38 | test("should have a number expiration", () => { 39 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).toEqual(expect.any(Number)); 40 | expect(session.expirationDate).toEqual(expect.any(Number)); 41 | }); 42 | 43 | test("should have an integer expiration", () => { 44 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION) % 1).toEqual(0); 45 | expect(session.expirationDate %1).toEqual(0); 46 | }); 47 | 48 | }); 49 | 50 | describe('Clear Session', () => { 51 | const storage = new Storage("1234"); 52 | const session = new Session({storage: storage}); 53 | session.expireSession(); 54 | 55 | test("should clear session id", () => { 56 | expect(storage.get(session.StorageKeys.SESSION_ID)).not.toBeDefined(); 57 | }); 58 | 59 | test("should clear session expiration", () => { 60 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).not.toBeDefined(); 61 | }); 62 | }); 63 | 64 | 65 | describe('Extend Session (Default Values)', () => { 66 | const storage = new Storage("1234"); 67 | let session = new Session({storage: storage}); 68 | let expiration = session.expirationDate; 69 | session.extendSession(); 70 | 71 | test("should not be original expiration date", () => { 72 | expect(expiration).not.toEqual(session.expirationDate); 73 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).not.toEqual(expiration); 74 | }); 75 | 76 | test("should persist new expiration date", () => { 77 | session = new AMA.Session({storage: storage}); 78 | expiration = session.expirationDate; 79 | session.extendSession(); 80 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).toEqual(session.expirationDate); 81 | }); 82 | 83 | test("should be 30min later", () => { 84 | expect(session.expirationDate).toEqual(expiration + session.sessionLength); 85 | }); 86 | }); 87 | 88 | describe('Extend Session (Default Values)', () => { 89 | const storage = new Storage("1234"); 90 | let session = new Session({storage: storage}); 91 | let expiration = session.expirationDate; 92 | session.extendSession(60000); 93 | 94 | test("should not be original expiration date", () => { 95 | expect(expiration).not.toEqual(session.expirationDate); 96 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).not.toEqual(expiration); 97 | }); 98 | 99 | test("should persist new expiration date", () => { 100 | session = new AMA.Session({storage: storage}); 101 | expiration = session.expirationDate; 102 | session.extendSession(60000); 103 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).toEqual(session.expirationDate); 104 | }); 105 | 106 | test("should be 60 sec later", () => { 107 | expect(session.expirationDate).toEqual(expiration + 60000); 108 | }); 109 | }); 110 | 111 | describe('Reset Session Timeout (1 min from now)', () => { 112 | const storage = new Storage("1234"); 113 | let session = new Session({storage: storage}); 114 | let expiration = session.expirationDate; 115 | session.resetSessionTimeout(60000); 116 | 117 | test("should not be original expiration date", () => { 118 | expect(expiration).not.toEqual(session.expirationDate); 119 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).not.toEqual(expiration); 120 | }); 121 | 122 | test("should persist new expiration date", () => { 123 | expect(storage.get(session.StorageKeys.SESSION_EXPIRATION)).toEqual(session.expirationDate); 124 | }); 125 | 126 | }); 127 | 128 | }); -------------------------------------------------------------------------------- /src/__tests__/MobileAnalyticsUtilities.test.js: -------------------------------------------------------------------------------- 1 | import Util from "../MobileAnalyticsUtilities"; 2 | 3 | 4 | describe('Util', () => { 5 | 6 | describe('GUID', () => { 7 | test("should not be equal", () => { 8 | expect(Util.GUID()).not.toEqual(Util.GUID()); 9 | }); 10 | }); 11 | 12 | describe('mergeObjects', () => { 13 | let a = {a: 1, b: 2}; 14 | let b = {b: 1, c: 3}; 15 | 16 | test("should merge with overlapping keys", () => { 17 | expect(Util.mergeObjects(a, b)).toEqual({a:1, b:2, c:3}); 18 | }); 19 | 20 | test("should mutate original", () => { 21 | expect(Util.mergeObjects(a, b)).toEqual(a); 22 | }); 23 | }); 24 | 25 | 26 | describe('utf8ByteLength', () => { 27 | test("should test char codes > 127", () => { 28 | expect(Util.getRequestBodySize('©‰')).toEqual(5); 29 | }); 30 | 31 | test("should test trail surrogate", () => { 32 | expect(Util.getRequestBodySize('𝌆')).toEqual(4); 33 | }); 34 | }); 35 | 36 | describe('copy', () => { 37 | let a = {a: 1, b: 2}; 38 | let b = {c: 3}; 39 | 40 | test("should copy with new keys", () => { 41 | expect(Util.copy(a, b)).toEqual({a:1, b:2, c:3}); 42 | }); 43 | 44 | test("should not mutate original", () => { 45 | expect(Util.copy(a, b)).not.toEqual(a); 46 | }); 47 | }); 48 | 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/Storage.test.js: -------------------------------------------------------------------------------- 1 | import Storage from "../Storage"; 2 | 3 | describe('Storage', () => { 4 | 5 | test("should initialize storage with appId and cache", () => { 6 | let storage = new Storage("1234"); 7 | 8 | // check appId 9 | expect(storage.storageKey).toBe('AWSMobileAnalyticsStorage-' + "1234"); 10 | expect(storage.storageKey).not.toBe('AWSMobileAnalyticsStorage-' + "124"); 11 | 12 | 13 | // check cache 14 | expect(storage.cache).toMatchObject({}); 15 | expect(storage.cache).not.toMatchObject({something: "something"}); 16 | }); 17 | 18 | test("should set and get correct item of cache", () => { 19 | let storage = new Storage("1234"); 20 | 21 | // set 22 | storage.set("someKey", "someValue"); 23 | expect(storage.cache).toMatchObject({"someKey": "someValue"}); 24 | 25 | // get 26 | expect(storage.get("someKey")).toBe("someValue"); 27 | 28 | }) 29 | ; 30 | 31 | 32 | test("should set and delete item of cache", () => { 33 | let storage = new Storage("1234"); 34 | 35 | // set 36 | storage.set("someKey", "someValue"); 37 | storage.set("someKey2", "someValue2"); 38 | 39 | // delete 40 | storage.delete("someKey"); 41 | expect(storage.get("someKey")).not.toBeDefined(); 42 | expect(storage.get("someKey2")).toBeDefined(); 43 | }); 44 | 45 | 46 | test("should get each key in cache", () => { 47 | let storage = new Storage("1234"); 48 | 49 | // keys 50 | let keys = ["someKey1", "someKey2", "someKey3"]; 51 | let keysInCache = []; 52 | 53 | // set 54 | storage.set(keys[0], "someValue1"); 55 | storage.set(keys[1], "someValue2"); 56 | storage.set(keys[2], "someValue3"); 57 | 58 | 59 | // each 60 | storage.each((key) => { 61 | keysInCache.push(key); 62 | }); 63 | 64 | // compare keys and keysInCache 65 | for (let k in keys.keys()) { 66 | expect(keysInCache).toContain(k); 67 | } 68 | }); 69 | 70 | }); --------------------------------------------------------------------------------