@aws-amplify/analytics
Version:
Analytics category of aws-amplify
686 lines (603 loc) • 18.3 kB
text/typescript
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { Cache } from '@aws-amplify/cache';
import {
ConsoleLogger as Logger,
ClientDevice,
Credentials,
Signer,
Hub,
transferKeyToLowerCase,
transferKeyToUpperCase,
AnalyticsAction,
} from '@aws-amplify/core';
import {
putEvents,
PutEventsInput,
PutEventsOutput,
updateEndpoint,
UpdateEndpointInput,
UpdateEndpointOutput,
} from '@aws-amplify/core/internals/aws-clients/pinpoint';
import {
AnalyticsProvider,
PromiseHandlers,
EndpointBuffer,
EventParams,
EventObject,
EndpointFailureData,
} from '../types';
import { v1 as uuid } from 'uuid';
import { getAnalyticsUserAgentString } from '../utils/UserAgent';
import EventBuffer from './EventBuffer';
const AMPLIFY_SYMBOL = (
typeof Symbol !== 'undefined' && typeof Symbol.for === 'function'
? Symbol.for('amplify_default')
: '@@amplify_default'
) as Symbol;
const dispatchAnalyticsEvent = (event, data) => {
Hub.dispatch('analytics', { event, data }, 'Analytics', AMPLIFY_SYMBOL);
};
const logger = new Logger('AWSPinpointProvider');
const RETRYABLE_CODES = [429, 500];
const ACCEPTED_CODES = [202];
const FORBIDDEN_CODE = 403;
const MOBILE_SERVICE_NAME = 'mobiletargeting';
const EXPIRED_TOKEN_CODE = 'ExpiredTokenException';
const UPDATE_ENDPOINT = '_update_endpoint';
const SESSION_START = '_session.start';
const SESSION_STOP = '_session.stop';
const BEACON_SUPPORTED =
typeof navigator !== 'undefined' &&
navigator &&
typeof navigator.sendBeacon === 'function';
// events buffer
const BUFFER_SIZE = 1000;
const FLUSH_SIZE = 100;
const FLUSH_INTERVAL = 5 * 1000; // 5s
const RESEND_LIMIT = 5;
// params: { event: {name: , .... }, timeStamp, config, resendLimits }
export class AWSPinpointProvider implements AnalyticsProvider {
static category = 'Analytics';
static providerName = 'AWSPinpoint';
private _config;
private _sessionId;
private _sessionStartTimestamp;
private _buffer: EventBuffer | null;
private _endpointBuffer: EndpointBuffer;
private _clientInfo;
private _endpointGenerating = true;
private _endpointUpdateInProgress = false;
constructor(config?) {
this._buffer = null;
this._endpointBuffer = [];
this._config = config ? config : {};
this._config.bufferSize = this._config.bufferSize || BUFFER_SIZE;
this._config.flushSize = this._config.flushSize || FLUSH_SIZE;
this._config.flushInterval = this._config.flushInterval || FLUSH_INTERVAL;
this._config.resendLimit = this._config.resendLimit || RESEND_LIMIT;
this._clientInfo = ClientDevice.clientInfo();
}
/**
* get the category of the plugin
*/
getCategory(): string {
return AWSPinpointProvider.category;
}
/**
* get provider name of the plugin
*/
getProviderName(): string {
return AWSPinpointProvider.providerName;
}
/**
* configure the plugin
* @param {Object} config - configuration
*/
public configure(config): object {
logger.debug('configure Analytics', config);
const conf = config || {};
this._config = Object.assign({}, this._config, conf);
// If autoSessionRecord is enabled, we need to wait for the endpoint to be
// updated before sending any events. See `sendEvents` in `Analytics.ts`
this._endpointGenerating = !!config['autoSessionRecord'];
if (this._config.appId && !this._config.disabled) {
if (!this._config.endpointId) {
const cacheKey = this.getProviderName() + '_' + this._config.appId;
this._getEndpointId(cacheKey)
.then(endpointId => {
logger.debug('setting endpoint id from the cache', endpointId);
this._config.endpointId = endpointId;
dispatchAnalyticsEvent('pinpointProvider_configured', null);
})
.catch(err => {
logger.debug('Failed to generate endpointId', err);
});
} else {
dispatchAnalyticsEvent('pinpointProvider_configured', null);
}
} else {
this._flushBuffer();
}
return this._config;
}
/**
* record an event
* @param {Object} params - the params of an event
*/
public async record(params: EventParams, handlers: PromiseHandlers) {
logger.debug('_public record', params);
const credentials = await this._getCredentials();
if (!credentials || !this._config.appId || !this._config.region) {
logger.debug(
'cannot send events without credentials, applicationId or region'
);
return handlers.reject(
new Error('No credentials, applicationId or region')
);
}
this._init(credentials);
const timestamp = new Date().getTime();
// attach the session and eventId
this._generateSession(params);
params.event.eventId = uuid();
Object.assign(params, { timestamp, config: this._config });
if (params.event.immediate) {
return this._send(params, handlers);
} else {
this._putToBuffer(params, handlers);
}
}
private async _sendEndpointUpdate(endpointObject: EventObject) {
if (this._endpointUpdateInProgress) {
this._endpointBuffer.push(endpointObject);
return;
}
this._endpointUpdateInProgress = true;
await this._updateEndpoint(endpointObject);
const next = this._endpointBuffer.shift();
this._endpointUpdateInProgress = false;
next && this._sendEndpointUpdate(next);
}
/**
* @private
* @param params - params for event recording
* Put events into buffer
*/
private _putToBuffer(params, handlers) {
if (params.event.name === UPDATE_ENDPOINT) {
this._sendEndpointUpdate({ params, handlers });
return;
}
this._buffer?.push({ params, handlers });
}
private _generateSession(params) {
this._sessionId = this._sessionId || uuid();
const { event } = params;
switch (event.name) {
case SESSION_START:
// refresh the session id and session start time
this._sessionStartTimestamp = new Date().getTime();
this._sessionId = uuid();
event.session = {
Id: this._sessionId,
StartTimestamp: new Date(this._sessionStartTimestamp).toISOString(),
};
break;
case SESSION_STOP:
const stopTimestamp = new Date().getTime();
this._sessionStartTimestamp =
this._sessionStartTimestamp || new Date().getTime();
this._sessionId = this._sessionId || uuid();
event.session = {
Id: this._sessionId,
Duration: stopTimestamp - this._sessionStartTimestamp,
StartTimestamp: new Date(this._sessionStartTimestamp).toISOString(),
StopTimestamp: new Date(stopTimestamp).toISOString(),
};
this._sessionId = undefined;
this._sessionStartTimestamp = undefined;
break;
default:
this._sessionStartTimestamp =
this._sessionStartTimestamp || new Date().getTime();
this._sessionId = this._sessionId || uuid();
event.session = {
Id: this._sessionId,
StartTimestamp: new Date(this._sessionStartTimestamp).toISOString(),
};
}
}
private async _send(params, handlers) {
const { event } = params;
switch (event.name) {
case UPDATE_ENDPOINT:
return this._updateEndpoint({ params, handlers });
case SESSION_STOP:
return this._pinpointSendStopSession(params, handlers);
default:
return this._pinpointPutEvents(params, handlers);
}
}
private _generateBatchItemContext(params): PutEventsInput {
const { event, timestamp, config } = params;
const { name, attributes, metrics, eventId, session } = event;
const { appId, endpointId } = config;
const endpointContext = {};
return {
ApplicationId: appId,
EventsRequest: {
BatchItem: {
[endpointId]: {
Endpoint: endpointContext,
Events: {
[eventId]: {
EventType: name,
Timestamp: new Date(timestamp).toISOString(),
Attributes: attributes,
Metrics: metrics,
Session: session,
},
},
},
},
},
};
}
private async _pinpointPutEvents(params, handlers) {
const {
event: { eventId },
config: { endpointId },
} = params;
const eventParams = this._generateBatchItemContext(params);
try {
const { credentials, region } = this._config;
const data: PutEventsOutput = await putEvents(
{
credentials,
region,
userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.Record),
},
eventParams
);
const { StatusCode, Message } =
data.EventsResponse?.Results?.[endpointId]?.EventsItemResponse?.[
eventId
] ?? {};
if (StatusCode && ACCEPTED_CODES.includes(StatusCode)) {
logger.debug('record event success. ', data);
return handlers.resolve(data);
} else if (StatusCode && RETRYABLE_CODES.includes(StatusCode)) {
// TODO: v6 integrate retry to the service handler retryDecider
this._retry(params, handlers);
} else {
logger.error(
`Event ${eventId} is not accepted, the error is ${Message}`
);
return handlers.reject(data);
}
} catch (err) {
this._eventError(err);
return handlers.reject(err);
}
}
private _pinpointSendStopSession(params, handlers): Promise<string> | void {
if (!BEACON_SUPPORTED) {
this._pinpointPutEvents(params, handlers);
return;
}
const eventParams = this._generateBatchItemContext(params);
const { region } = this._config;
const { ApplicationId, EventsRequest } = eventParams;
const accessInfo = {
secret_key: this._config.credentials.secretAccessKey,
access_key: this._config.credentials.accessKeyId,
session_token: this._config.credentials.sessionToken,
};
const url = `https://pinpoint.${region}.amazonaws.com/v1/apps/${ApplicationId}/events/legacy`;
const body = JSON.stringify(EventsRequest);
const method = 'POST';
const request = {
url,
body,
method,
};
const serviceInfo = { region, service: MOBILE_SERVICE_NAME };
const requestUrl: string = Signer.signUrl(request, accessInfo, serviceInfo);
const success: boolean = navigator.sendBeacon(requestUrl, body);
if (success) {
return handlers.resolve('sendBeacon success');
}
return handlers.reject('sendBeacon failure');
}
private _retry(params, handlers) {
const {
config: { resendLimit },
} = params;
// For backward compatibility
params.resendLimit =
typeof params.resendLimit === 'number' ? params.resendLimit : resendLimit;
if (params.resendLimit-- > 0) {
logger.debug(
`resending event ${params.eventName} with ${params.resendLimit} retry times left`
);
this._pinpointPutEvents(params, handlers);
} else {
logger.debug(`retry times used up for event ${params.eventName}`);
}
}
private async _updateEndpoint(endpointObject: EventObject) {
const { params, handlers } = endpointObject;
const { config, event } = params;
const { appId, endpointId } = config;
const request = this._endpointRequest(
config,
transferKeyToLowerCase(
event,
[],
['attributes', 'userAttributes', 'Attributes', 'UserAttributes']
)
);
const update_params: UpdateEndpointInput = {
ApplicationId: appId,
EndpointId: endpointId,
EndpointRequest: request,
};
try {
const { credentials, region } = this._config;
const data: UpdateEndpointOutput = await updateEndpoint(
{
credentials,
region,
userAgentValue: getAnalyticsUserAgentString(
AnalyticsAction.UpdateEndpoint
),
},
update_params
);
logger.debug('updateEndpoint success', data);
this._endpointGenerating = false;
this._resumeBuffer();
handlers.resolve(data);
return;
} catch (err) {
const failureData: EndpointFailureData = {
err,
update_params,
endpointObject,
};
return this._handleEndpointUpdateFailure(failureData);
}
}
private async _handleEndpointUpdateFailure(failureData: EndpointFailureData) {
const { err, endpointObject } = failureData;
const statusCode = err.$metadata && err.$metadata.httpStatusCode;
logger.debug('updateEndpoint error', err);
switch (statusCode) {
case FORBIDDEN_CODE:
return this._handleEndpointUpdateForbidden(failureData);
default:
if (RETRYABLE_CODES.includes(statusCode)) {
// Server error. Attempt exponential retry
const exponential = true;
return this._retryEndpointUpdate(endpointObject, exponential);
}
logger.error('updateEndpoint failed', err);
endpointObject.handlers.reject(err);
}
}
private _handleEndpointUpdateForbidden(failureData: EndpointFailureData) {
const { err, endpointObject } = failureData;
const { code, retryable } = err;
if (code !== EXPIRED_TOKEN_CODE && !retryable) {
return endpointObject.handlers.reject(err);
}
this._retryEndpointUpdate(endpointObject);
}
private _retryEndpointUpdate(
endpointObject: EventObject,
exponential: boolean = false
) {
logger.debug('_retryEndpointUpdate', endpointObject);
const { params } = endpointObject;
// TODO: implement retry with exp back off once exp function is available
const {
config: { resendLimit },
} = params;
params.resendLimit =
typeof params.resendLimit === 'number' ? params.resendLimit : resendLimit;
if (params.resendLimit-- > 0) {
logger.debug(
`resending endpoint update ${params.event.eventId} with ${params.resendLimit} retry attempts remaining`
);
// insert at the front of endpointBuffer
this._endpointBuffer.length
? this._endpointBuffer.unshift(endpointObject)
: this._updateEndpoint(endpointObject);
return;
}
logger.warn(
`resending endpoint update ${params.event.eventId} failed after ${params.config.resendLimit} attempts`
);
if (this._endpointGenerating) {
logger.error('Initial endpoint update failed. ');
}
}
/**
* @private
* @param config
* Configure credentials and init buffer
*/
private async _init(credentials) {
logger.debug('init provider');
if (
this._config.credentials &&
this._config.credentials.sessionToken === credentials.sessionToken &&
this._config.credentials.identityId === credentials.identityId
) {
logger.debug('no change for aws credentials, directly return from init');
return;
}
const identityId = this._config.credentials
? this._config.credentials.identityId
: null;
this._config.credentials = credentials;
if (!this._bufferExists() || identityId !== credentials.identityId) {
// if the identity has changed, flush the buffer and instantiate a new one
// this will cause the old buffer to send any remaining events
// with the old credentials and then stop looping and shortly thereafter get picked up by GC
this._initBuffer();
}
}
private _bufferExists() {
return this._buffer && this._buffer instanceof EventBuffer;
}
private _initBuffer() {
if (this._bufferExists()) {
this._flushBuffer();
}
this._buffer = new EventBuffer(this._config);
// if the first endpoint update hasn't yet resolved pause the buffer to
// prevent race conditions. It will be resumed as soon as that request succeeds
if (this._endpointGenerating) {
this._buffer.pause();
}
}
private _flushBuffer() {
if (this._bufferExists()) {
this._buffer?.flush();
this._buffer = null;
}
}
private _resumeBuffer() {
if (this._bufferExists()) {
this._buffer?.resume();
}
}
private async _getEndpointId(cacheKey) {
// try to get from cache
let endpointId = await Cache.getItem(cacheKey);
logger.debug(
'endpointId from cache',
endpointId,
'type',
typeof endpointId
);
if (!endpointId) {
endpointId = uuid();
// set a longer TTL to avoid endpoint id being deleted after the default TTL (3 days)
// also set its priority to the highest to reduce its chance of being deleted when cache is full
const ttl = 1000 * 60 * 60 * 24 * 365 * 100; // 100 years
const expiration = new Date().getTime() + ttl;
Cache.setItem(cacheKey, endpointId, {
expires: expiration,
priority: 1,
});
}
return endpointId;
}
/**
* EndPoint request
* @return {Object} - The request of updating endpoint
*/
private _endpointRequest(config, event) {
const { credentials } = config;
const clientInfo = this._clientInfo || {};
const clientContext = config.clientContext || {};
// for now we have three different ways for default endpoint configurations
// clientInfo
// clientContext (deprecated)
// config.endpoint
const defaultEndpointConfig = config.endpoint || {};
const demographicByClientInfo = {
appVersion: clientInfo.appVersion,
make: clientInfo.make,
model: clientInfo.model,
modelVersion: clientInfo.version,
platform: clientInfo.platform,
};
// for backward compatibility
const {
clientId,
appTitle,
appVersionName,
appVersionCode,
appPackageName,
...demographicByClientContext
} = clientContext;
const channelType = event.address
? clientInfo.platform === 'android'
? 'GCM'
: 'APNS'
: undefined;
const tmp = {
channelType,
requestId: uuid(),
effectiveDate: new Date().toISOString(),
...defaultEndpointConfig,
...event,
attributes: {
...defaultEndpointConfig.attributes,
...event.attributes,
},
demographic: {
...demographicByClientInfo,
...demographicByClientContext,
...defaultEndpointConfig.demographic,
...event.demographic,
},
location: {
...defaultEndpointConfig.location,
...event.location,
},
metrics: {
...defaultEndpointConfig.metrics,
...event.metrics,
},
user: {
userId:
event.userId ||
defaultEndpointConfig.userId ||
credentials.identityId,
userAttributes: {
...defaultEndpointConfig.userAttributes,
...event.userAttributes,
},
},
};
// eliminate unnecessary params
const {
userId,
userAttributes,
name,
session,
eventId,
immediate,
...ret
} = tmp;
return transferKeyToUpperCase(
ret,
[],
['metrics', 'userAttributes', 'attributes']
);
}
private _eventError(err: any) {
logger.error('record event failed.', err);
logger.warn(
`Please ensure you have updated your Pinpoint IAM Policy ` +
`with the Action: "mobiletargeting:PutEvents" ` +
`in order to record events`
);
}
private async _getCredentials() {
try {
const credentials = await Credentials.get();
if (!credentials) return null;
logger.debug('set credentials for analytics', credentials);
return Credentials.shear(credentials);
} catch (err) {
logger.debug('ensure credentials error', err);
return null;
}
}
}