var AWS = require('aws-sdk');
var AMA = global.AMA;
AMA.Storage = require('./StorageClients/LocalStorage.js');
AMA.StorageKeys = require('./StorageClients/StorageKeys.js');
AMA.Util = require('./MobileAnalyticsUtilities.js');
/**
* @typedef AMA.Client.Options
* @property {string} appId - The Application ID from the Amazon Mobile Analytics Console
* @property {string} [apiVersion=2014-06-05] - The version of the Mobile Analytics API to submit to.
* @property {object} [provider=AWS.config.credentials] - Credentials to use for submitting events.
* **Never check in credentials to source
* control.
* @property {boolean} [autoSubmitEvents=true] - Automatically Submit Events, Default: true
* @property {number} [autoSubmitInterval=10000] - Interval to try to submit events in ms,
* Default: 10s
* @property {number} [batchSizeLimit=256000] - Batch Size in Bytes, Default: 256Kb
* @property {AMA.Client.SubmitCallback} [submitCallback=] - Callback function that is executed when events are
* successfully submitted
* @property {AMA.Client.Attributes} [globalAttributes=] - Attribute to be applied to every event, may be
* overwritten with a different value when recording events.
* @property {AMA.Client.Metrics} [globalMetrics=] - Metric to be applied to every event, may be overwritten
* with a different value when recording events.
* @property {string} [clientId=GUID()] - A unique identifier representing this installation instance
* of your app. This will be managed and persisted by the SDK
* by default.
* @property {string} [appTitle=] - The title of your app. For example, My App.
* @property {string} [appVersionName=] - The version of your app. For example, V2.0.
* @property {string} [appVersionCode=] - The version code for your app. For example, 3.
* @property {string} [appPackageName=] - The name of your package. For example, com.example.my_app.
* @property {string} [platform=] - The operating system of the device. For example, iPhoneOS.
* @property {string} [plaformVersion=] - The version of the operating system of the device.
* For example, 4.0.4.
* @property {string} [model=] - The model of the device. For example, Nexus.
* @property {string} [make=] - The manufacturer of the device. For example, Samsung.
* @property {string} [locale=] - The locale of the device. For example, en_US.
* @property {AMA.Client.Logger} [logger=] - Object of logger functions
* @property {AMA.Storage} [storage=] - Storage client to persist events, will create a new AMA.Storage if not provided
* @property {Object} [clientOptions=] - Low level client options to be passed to the AWS.MobileAnalytics low level SDK
*/
/**
* @typedef AMA.Client.Logger
* @description Uses Javascript Style log levels, one function for each level. Basic usage is to pass the console object
* which will output directly to browser developer console.
* @property {Function} [log=] - Logger for client log level messages
* @property {Function} [info=] - Logger for interaction level messages
* @property {Function} [warn=] - Logger for warn level messages
* @property {Function} [error=] - Logger for error level messages
*/
/**
* @typedef AMA.Client.Attributes
* @type {object}
* @description A collection of key-value pairs that give additional context to the event. The key-value pairs are
* specified by the developer.
*/
/**
* @typedef AMA.Client.Metrics
* @type {object}
* @description A collection of key-value pairs that gives additional measurable context to the event. The pairs
* specified by the developer.
*/
/**
* @callback AMA.Client.SubmitCallback
* @param {Error} err
* @param {Null} data
* @param {string} batchId
*/
/**
* @typedef AMA.Client.Event
* @type {object}
* @description A JSON object representing an event occurrence in your app and consists of the following:
* @property {string} eventType - A name signifying an event that occurred in your app. This is used for grouping and
* aggregating like events together for reporting purposes.
* @property {string} timestamp - The time the event occurred in ISO 8601 standard date time format.
* For example, 2014-06-30T19:07:47.885Z
* @property {AMA.Client.Attributes} [attributes=] - A collection of key-value pairs that give additional context to
* the event. The key-value pairs are specified by the developer.
* This collection can be empty or the attribute object can be omitted.
* @property {AMA.Client.Metrics} [metrics=] - A collection of key-value pairs that gives additional measurable context
* to the event. The pairs specified by the developer.
* @property {AMA.Session} session - Describes the session. Session information is required on ALL events.
*/
/**
* @name AMA.Client
* @namespace AMA.Client
* @constructor
* @param {AMA.Client.Options} options - A configuration map for the AMA.Client
* @returns A new instance of the Mobile Analytics Mid Level Client
*/
AMA.Client = (function () {
'use strict';
/**
* @lends AMA.Client
*/
var Client = function (options) {
//This register the bind function for older browsers
//https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
if (!Function.prototype.bind) {
Function.prototype.bind = function (oThis) {
if (typeof this !== 'function') {
// closest thing possible to the ECMAScript 5 internal IsCallable function
//throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');
this.logger.error('Function.prototype.bind - what is trying to be bound is not callable');
}
var aArgs = Array.prototype.slice.call(arguments, 1),
fToBind = this,
fBound = function () {
return fToBind.apply(
this instanceof AMA.Util.NOP && oThis ? this : oThis,
aArgs.concat(Array.prototype.slice.call(arguments))
);
};
AMA.Util.NOP.prototype = this.prototype;
fBound.prototype = new AMA.Util.NOP();
return fBound;
};
}
this.options = options || {};
this.options.logger = this.options.logger || {};
this.logger = {
log : this.options.logger.log || AMA.Util.NOP,
info : this.options.logger.info || AMA.Util.NOP,
warn : this.options.logger.warn || AMA.Util.NOP,
error: this.options.logger.error || AMA.Util.NOP
};
this.logger.log = this.logger.log.bind(this.options.logger);
this.logger.info = this.logger.info.bind(this.options.logger);
this.logger.warn = this.logger.warn.bind(this.options.logger);
this.logger.error = this.logger.error.bind(this.options.logger);
this.logger.log('[Function:(AMA)Client Constructor]' +
(options ? '\noptions:' + JSON.stringify(options) : ''));
if (options.appId === undefined) {
this.logger.error('AMA.Client must be initialized with an appId');
return null; //No need to run rest of init since appId is required
}
if (options.platform === undefined) {
this.logger.error('AMA.Client must be initialized with a platform');
}
this.storage = this.options.storage || new AMA.Storage(options.appId);
this.storage.setLogger(this.logger);
this.options.apiVersion = this.options.apiVersion || '2014-06-05';
this.options.provider = this.options.provider || AWS.config.credentials;
this.options.autoSubmitEvents = options.autoSubmitEvents !== false;
this.options.autoSubmitInterval = this.options.autoSubmitInterval || 10000;
this.options.batchSizeLimit = this.options.batchSizeLimit || 256000;
this.options.submitCallback = this.options.submitCallback || AMA.Util.NOP;
this.options.globalAttributes = this.options.globalAttributes || {};
this.options.globalMetrics = this.options.globalMetrics || {};
this.options.clientOptions = this.options.clientOptions || {};
this.options.clientOptions.provider = this.options.clientOptions.provider || this.options.provider;
this.options.clientOptions.apiVersion = this.options.clientOptions.apiVersion || this.options.apiVersion;
this.options.clientOptions.correctClockSkew = this.options.clientOptions.correctClockSkew !== false;
this.options.clientOptions.retryDelayOptions = this.options.clientOptions.retryDelayOptions || {};
this.options.clientOptions.retryDelayOptions.base = this.options.clientOptions.retryDelayOptions.base || 3000;
this.storage.set(
AMA.StorageKeys.GLOBAL_ATTRIBUTES,
AMA.Util.mergeObjects(this.options.globalAttributes,
this.storage.get(AMA.StorageKeys.GLOBAL_ATTRIBUTES) || {})
);
this.storage.set(
AMA.StorageKeys.GLOBAL_METRICS,
AMA.Util.mergeObjects(this.options.globalMetrics,
this.storage.get(AMA.StorageKeys.GLOBAL_METRICS) || {})
);
var v091ClientId = this.storage.get(AMA.StorageKeys.CLIENT_ID);
try {
if (window && window.localStorage) {
var v090Storage = window.localStorage.getItem('AWSMobileAnalyticsStorage');
if (v090Storage) {
try {
v090Storage = JSON.parse(v090Storage);
var v090ClientId = v090Storage.AWSMobileAnalyticsClientId;
if (v090ClientId && v091ClientId && v091ClientId !== v090ClientId) {
this.options.globalAttributes.migrationId = v091ClientId;
}
if (v090ClientId) {
v091ClientId = v090ClientId;
}
} catch (err) {
this.logger.warn('Had corrupt v0.9.0 Storage');
}
}
}
} catch (err) {
this.logger.warn('window is undefined, unable to check for v090 data');
}
this.options.clientContext = this.options.clientContext || {
'client' : {
'client_id' : this.options.clientId || v091ClientId || AMA.Util.GUID(),
'app_title' : this.options.appTitle,
'app_version_name': this.options.appVersionName,
'app_version_code': this.options.appVersionCode,
'app_package_name': this.options.appPackageName
},
'env' : {
'platform' : this.options.platform,
'platform_version': this.options.platformVersion,
'model' : this.options.model,
'make' : this.options.make,
'locale' : this.options.locale
},
'services': {
'mobile_analytics': {
'app_id' : this.options.appId,
'sdk_name' : '<%= sdk_name %>-js',
'sdk_version': '<%= sdk_version %>' + ':' + AWS.VERSION
}
},
'custom' : {}
};
this.storage.set(AMA.StorageKeys.CLIENT_ID, this.options.clientContext.client.client_id);
this.StorageKeys = {
'EVENTS' : 'AWSMobileAnalyticsEventStorage',
'BATCHES' : 'AWSMobileAnalyticsBatchStorage',
'BATCH_INDEX': 'AWSMobileAnalyticsBatchIndexStorage'
};
this.outputs = {};
this.outputs.MobileAnalytics = new AWS.MobileAnalytics(this.options.clientOptions);
this.outputs.timeoutReference = null;
this.outputs.batchesInFlight = {};
this.outputs.events = this.storage.get(this.StorageKeys.EVENTS) || [];
this.outputs.batches = this.storage.get(this.StorageKeys.BATCHES) || {};
this.outputs.batchIndex = this.storage.get(this.StorageKeys.BATCH_INDEX) || [];
if (this.options.autoSubmitEvents) {
this.submitEvents();
}
};
Client.prototype.validateEvent = function (event) {
var self = this, invalidMetrics = [];
function customNameErrorFilter(name) {
if (name.length === 0) {
return true;
}
return name.length > 50;
}
function customAttrValueErrorFilter(name) {
return event.attributes[name] && event.attributes[name].length > 200;
}
function validationError(errorMsg) {
self.logger.error(errorMsg);
return null;
}
invalidMetrics = Object.keys(event.metrics).filter(function (metricName) {
return typeof event.metrics[metricName] !== 'number';
});
if (event.version !== 'v2.0') {
return validationError('Event must have version v2.0');
}
if (typeof event.eventType !== 'string') {
return validationError('Event Type must be a string');
}
if (invalidMetrics.length > 0) {
return validationError('Event Metrics must be numeric (' + invalidMetrics[0] + ')');
}
if (Object.keys(event.metrics).length + Object.keys(event.attributes).length > 40) {
return validationError('Event Metric and Attribute Count cannot exceed 40');
}
if (Object.keys(event.attributes).filter(customNameErrorFilter).length) {
return validationError('Event Attribute names must be 1-50 characters');
}
if (Object.keys(event.metrics).filter(customNameErrorFilter).length) {
return validationError('Event Metric names must be 1-50 characters');
}
if (Object.keys(event.attributes).filter(customAttrValueErrorFilter).length) {
return validationError('Event Attribute values cannot be longer than 200 characters');
}
return event;
};
/**
* AMA.Client.createEvent
* @param {string} eventType - Custom Event Type to be displayed in Console
* @param {AMA.Session} session - Session Object (required for use within console)
* @param {string} session.id - Identifier for current session
* @param {string} session.startTimestamp - Timestamp that indicates the start of the session
* @param [attributes=] - Custom attributes
* @param [metrics=] - Custom metrics
* @returns {AMA.Event}
*/
Client.prototype.createEvent = function (eventType, session, attributes, metrics) {
var that = this;
this.logger.log('[Function:(AMA.Client).createEvent]' +
(eventType ? '\neventType:' + eventType : '') +
(session ? '\nsession:' + session : '') +
(attributes ? '\nattributes:' + JSON.stringify(attributes) : '') +
(metrics ? '\nmetrics:' + JSON.stringify(metrics) : ''));
attributes = attributes || {};
metrics = metrics || {};
AMA.Util.mergeObjects(attributes, this.options.globalAttributes);
AMA.Util.mergeObjects(metrics, this.options.globalMetrics);
Object.keys(attributes).forEach(function (name) {
if (typeof attributes[name] !== 'string') {
try {
attributes[name] = JSON.stringify(attributes[name]);
} catch (e) {
that.logger.warn('Error parsing attribute ' + name);
}
}
});
var event = {
eventType : eventType,
timestamp : new Date().toISOString(),
session : {
id : session.id,
startTimestamp: session.startTimestamp
},
version : 'v2.0',
attributes: attributes,
metrics : metrics
};
if (session.stopTimestamp) {
event.session.stopTimestamp = session.stopTimestamp;
event.session.duration = new Date(event.stopTimestamp).getTime() - new Date(event.startTimestamp).getTime();
}
return this.validateEvent(event);
};
/**
* AMA.Client.pushEvent
* @param {AMA.Event} event - event to be pushed onto queue
* @returns {int} Index of event in outputs.events
*/
Client.prototype.pushEvent = function (event) {
if (!event) {
return -1;
}
this.logger.log('[Function:(AMA.Client).pushEvent]' +
(event ? '\nevent:' + JSON.stringify(event) : ''));
//Push adds to the end of array and returns the size of the array
var eventIndex = this.outputs.events.push(event);
this.storage.set(this.StorageKeys.EVENTS, this.outputs.events);
return (eventIndex - 1);
};
/**
* Helper to record events, will automatically submit if the events exceed batchSizeLimit
* @param {string} eventType - Custom event type name
* @param {AMA.Session} session - Session object
* @param {AMA.Client.Attributes} [attributes=] - Custom attributes
* @param {AMA.Client.Metrics} [metrics=] - Custom metrics
* @returns {AMA.Event} The event that was recorded
*/
Client.prototype.recordEvent = function (eventType, session, attributes, metrics) {
this.logger.log('[Function:(AMA.Client).recordEvent]' +
(eventType ? '\neventType:' + eventType : '') +
(session ? '\nsession:' + session : '') +
(attributes ? '\nattributes:' + JSON.stringify(attributes) : '') +
(metrics ? '\nmetrics:' + JSON.stringify(metrics) : ''));
var index, event = this.createEvent(eventType, session, attributes, metrics);
if (event) {
index = this.pushEvent(event);
if (AMA.Util.getRequestBodySize(this.outputs.events) >= this.options.batchSizeLimit) {
this.submitEvents();
}
return this.outputs.events[index];
}
return null;
};
/**
* recordMonetizationEvent
* @param session
* @param {Object} monetizationDetails - Details about Monetization Event
* @param {string} monetizationDetails.currency - ISO Currency of event
* @param {string} monetizationDetails.productId - Product Id of monetization event
* @param {number} monetizationDetails.quantity - Quantity of product in transaction
* @param {string|number} monetizationDetails.price - Price of product either ISO formatted string, or number
* with associated ISO Currency
* @param {AMA.Client.Attributes} [attributes=] - Custom attributes
* @param {AMA.Client.Metrics} [metrics=] - Custom metrics
* @returns {event} The event that was recorded
*/
Client.prototype.recordMonetizationEvent = function (session, monetizationDetails, attributes, metrics) {
this.logger.log('[Function:(AMA.Client).recordMonetizationEvent]' +
(session ? '\nsession:' + session : '') +
(monetizationDetails ? '\nmonetizationDetails:' + JSON.stringify(monetizationDetails) : '') +
(attributes ? '\nattributes:' + JSON.stringify(attributes) : '') +
(metrics ? '\nmetrics:' + JSON.stringify(metrics) : ''));
attributes = attributes || {};
metrics = metrics || {};
attributes._currency = monetizationDetails.currency || attributes._currency;
attributes._product_id = monetizationDetails.productId || attributes._product_id;
metrics._quantity = monetizationDetails.quantity || metrics._quantity;
if (typeof monetizationDetails.price === 'number') {
metrics._item_price = monetizationDetails.price || metrics._item_price;
} else {
attributes._item_price_formatted = monetizationDetails.price || attributes._item_price_formatted;
}
return this.recordEvent('_monetization.purchase', session, attributes, metrics);
};
/**
* submitEvents
* @param {Object} [options=] - options for submitting events
* @param {Object} [options.clientContext=this.options.clientContext] - clientContext to submit with defaults
* to options.clientContext
* @param {SubmitCallback} [options.submitCallback=this.options.submitCallback] - Callback function that is executed
* when events are successfully
* submitted
* @returns {Array} Array of batch indices that were submitted
*/
Client.prototype.submitEvents = function (options) {
options = options || {};
options.submitCallback = options.submitCallback || this.options.submitCallback;
this.logger.log('[Function:(AMA.Client).submitEvents]' +
(options ? '\noptions:' + JSON.stringify(options) : ''));
if (this.options.autoSubmitEvents) {
clearTimeout(this.outputs.timeoutReference);
this.outputs.timeoutReference = setTimeout(this.submitEvents.bind(this), this.options.autoSubmitInterval);
}
var warnMessage;
//Get distribution of retries across clients by introducing a weighted rand.
//Probability will increase over time to an upper limit of 60s
if (this.outputs.isThrottled && this.throttlingSuppressionFunction() < Math.random()) {
warnMessage = 'Prevented submission while throttled';
} else if (Object.keys(this.outputs.batchesInFlight).length > 0) {
warnMessage = 'Prevented submission while batches are in flight';
} else if (this.outputs.batches.length === 0 && this.outputs.events.length === 0) {
warnMessage = 'No batches or events to be submitted';
} else if (this.outputs.lastSubmitTimestamp && AMA.Util.timestamp() - this.outputs.lastSubmitTimestamp < 1000) {
warnMessage = 'Prevented multiple submissions in under a second';
}
if (warnMessage) {
this.logger.warn(warnMessage);
return [];
}
this.generateBatches();
this.outputs.lastSubmitTimestamp = AMA.Util.timestamp();
if (this.outputs.isThrottled) {
//Only submit the first batch if throttled
this.logger.warn('Is throttled submitting first batch');
options.batchId = this.outputs.batchIndex[0];
return [this.submitBatchById(options)];
}
return this.submitAllBatches(options);
};
Client.prototype.throttlingSuppressionFunction = function (timestamp) {
timestamp = timestamp || AMA.Util.timestamp();
return Math.pow(timestamp - this.outputs.lastSubmitTimestamp, 2) / Math.pow(60000, 2);
};
Client.prototype.generateBatches = function () {
while (this.outputs.events.length > 0) {
var lastIndex = this.outputs.events.length;
this.logger.log(this.outputs.events.length + ' events to be submitted');
while (lastIndex > 1 &&
AMA.Util.getRequestBodySize(this.outputs.events.slice(0, lastIndex)) > this.options.batchSizeLimit) {
this.logger.log('Finding Batch Size (' + this.options.batchSizeLimit + '): ' + lastIndex + '(' +
AMA.Util.getRequestBodySize(this.outputs.events.slice(0, lastIndex)) + ')');
lastIndex -= 1;
}
if (this.persistBatch(this.outputs.events.slice(0, lastIndex))) {
//Clear event queue
this.outputs.events.splice(0, lastIndex);
this.storage.set(this.StorageKeys.EVENTS, this.outputs.events);
}
}
};
Client.prototype.persistBatch = function (eventBatch) {
this.logger.log(eventBatch.length + ' events in batch');
if (AMA.Util.getRequestBodySize(eventBatch) < 512000) {
var batchId = AMA.Util.GUID();
//Save batch so data is not lost.
this.outputs.batches[batchId] = eventBatch;
this.storage.set(this.StorageKeys.BATCHES, this.outputs.batches);
this.outputs.batchIndex.push(batchId);
this.storage.set(this.StorageKeys.BATCH_INDEX, this.outputs.batchIndex);
return true;
}
this.logger.error('Events too large');
return false;
};
Client.prototype.submitAllBatches = function (options) {
options.submitCallback = options.submitCallback || this.options.submitCallback;
this.logger.log('[Function:(AMA.Client).submitAllBatches]' +
(options ? '\noptions:' + JSON.stringify(options) : ''));
var indices = [],
that = this;
this.outputs.batchIndex.forEach(function (batchIndex) {
options.batchId = batchIndex;
options.clientContext = options.clientContext || that.options.clientContext;
if (!that.outputs.batchesInFlight[batchIndex]) {
indices.push(that.submitBatchById(options));
}
});
return indices;
};
Client.NON_RETRYABLE_EXCEPTIONS = ['BadRequestException', 'SerializationException', 'ValidationException'];
Client.prototype.submitBatchById = function (options) {
if (typeof(options) !== 'object' || !options.batchId) {
this.logger.error('Invalid Options passed to submitBatchById');
return;
}
options.submitCallback = options.submitCallback || this.options.submitCallback;
this.logger.log('[Function:(AMA.Client).submitBatchById]' +
(options ? '\noptions:' + JSON.stringify(options) : ''));
var eventBatch = {
'events' : this.outputs.batches[options.batchId],
'clientContext': JSON.stringify(options.clientContext || this.options.clientContext)
};
this.outputs.batchesInFlight[options.batchId] = AMA.Util.timestamp();
this.outputs.MobileAnalytics.putEvents(eventBatch,
this.handlePutEventsResponse(options.batchId, options.submitCallback));
return options.batchId;
};
Client.prototype.handlePutEventsResponse = function (batchId, callback) {
var self = this;
return function (err, data) {
var clearBatch = true,
wasThrottled = self.outputs.isThrottled;
if (err) {
self.logger.error(err, data);
if (err.statusCode === undefined || err.statusCode === 400) {
if (Client.NON_RETRYABLE_EXCEPTIONS.indexOf(err.code) < 0) {
clearBatch = false;
}
self.outputs.isThrottled = err.code === 'ThrottlingException';
if (self.outputs.isThrottled) {
self.logger.warn('Application is currently throttled');
}
}
} else {
self.logger.info('Events Submitted Successfully');
self.outputs.isThrottled = false;
}
if (clearBatch) {
self.clearBatchById(batchId);
}
delete self.outputs.batchesInFlight[batchId];
callback(err, data, batchId);
if (wasThrottled && !self.outputs.isThrottled) {
self.logger.warn('Was throttled flushing remaining batches', callback);
self.submitAllBatches({
submitCallback: callback
});
}
};
};
Client.prototype.clearBatchById = function (batchId) {
this.logger.log('[Function:(AMA.Client).clearBatchById]' +
(batchId ? '\nbatchId:' + batchId : ''));
if (this.outputs.batchIndex.indexOf(batchId) !== -1) {
delete this.outputs.batches[batchId];
this.outputs.batchIndex.splice(this.outputs.batchIndex.indexOf(batchId), 1);
// Persist latest batches / events
this.storage.set(this.StorageKeys.BATCH_INDEX, this.outputs.batchIndex);
this.storage.set(this.StorageKeys.BATCHES, this.outputs.batches);
}
};
return Client;
}());
module.exports = AMA.Client;