Skip to content

Commit

Permalink
feat: add sendBeacon support (#412)
Browse files Browse the repository at this point in the history
* feat(beacon): add sendBeacon support

- add transport as option (either http or beacon)
- support sending event with either transport mechanism

* feat(beacon): add support for onExitPage handler

* feat(beacon): add tests

* feat(beacon): add clarifying comment

* add error callback logic to sendBeacon'

* move transport validation to init and check browser in validate function

* remove unneeded build files

* add new build files to gitignore

* fix lint issue from merge with master

* remove toLowerCase check that may error out if input is not string

Co-authored-by: AJ Horst <[email protected]>
  • Loading branch information
ajhorst and AJ Horst authored Aug 13, 2021
1 parent f744fe7 commit 0517038
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 6 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ amplitude-segment-snippet.min.js
package-lock.json
amplitude.umd.js
amplitude.umd.min.js
amplitude.native.js
amplitude.nocompat.js
amplitude.nocompat.min.js
78 changes: 73 additions & 5 deletions src/amplitude-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,31 @@ AmplitudeClient.prototype.init = function init(apiKey, opt_userId, opt_config, o
if (type(opt_callback) === 'function') {
opt_callback(this);
}

const onExitPage = this.options.onExitPage;
if (type(onExitPage) === 'function') {
if (!this.pageHandlersAdded) {
this.pageHandlersAdded = true;

const handleVisibilityChange = () => {
const prevTransport = this.options.transport;
this.setTransport(Constants.TRANSPORT_BEACON);
onExitPage();
this.setTransport(prevTransport);
};

// Monitoring just page exits because that is the most requested feature for now
// "If you're specifically trying to detect page unload events, the pagehide event is the best option."
// https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
window.addEventListener(
'pagehide',
() => {
handleVisibilityChange();
},
false,
);
}
}
} catch (err) {
utils.log.error(err);
if (type(opt_config.onError) === 'function') {
Expand Down Expand Up @@ -334,7 +359,9 @@ var _parseConfig = function _parseConfig(options, config) {

var inputValue = config[key];
var expectedType = type(options[key]);
if (!utils.validateInput(inputValue, key + ' option', expectedType)) {
if (key === 'transport' && !utils.validateTransport(inputValue)) {
return;
} else if (!utils.validateInput(inputValue, key + ' option', expectedType)) {
return;
}
if (expectedType === 'boolean') {
Expand Down Expand Up @@ -511,6 +538,13 @@ AmplitudeClient.prototype._sendEventsIfReady = function _sendEventsIfReady() {
return true;
}

// if beacon transport is activated, send events immediately
// because there is no way to retry them later
if (this.options.transport === Constants.TRANSPORT_BEACON) {
this.sendEvents();
return true;
}

// otherwise schedule an upload after 30s
if (!this._updateScheduled) {
// make sure we only schedule 1 upload
Expand Down Expand Up @@ -960,6 +994,25 @@ AmplitudeClient.prototype.setDeviceId = function setDeviceId(deviceId) {
}
};

/**
* Sets the network transport type for events. Typically used to set to 'beacon'
* on an end-of-lifecycle event handler such as `onpagehide` or `onvisibilitychange`
* @public
* @param {string} transport - transport mechanism to use for events. Must be one of `http` or `beacon`.
* @example amplitudeClient.setDeviceId('45f0954f-eb79-4463-ac8a-233a6f45a8f0');
*/
AmplitudeClient.prototype.setTransport = function setTransport(transport) {
if (this._shouldDeferCall()) {
return this._q.push(['setTransport'].concat(Array.prototype.slice.call(arguments, 0)));
}

if (!utils.validateTransport(transport)) {
return;
}

this.options.transport = transport;
};

/**
* Sets user properties for the current user.
* @public
Expand Down Expand Up @@ -1609,11 +1662,13 @@ AmplitudeClient.prototype.sendEvents = function sendEvents() {

// We only make one request at a time. sendEvents will be invoked again once
// the last request completes.
if (this._sending) {
return;
// beacon data is sent synchronously, so don't pause for it
if (this.options.transport !== Constants.TRANSPORT_BEACON) {
if (this._sending) {
return;
}
this._sending = true;
}

this._sending = true;
var protocol = this.options.forceHttps ? 'https' : 'https:' === window.location.protocol ? 'https' : 'http';
var url = protocol + '://' + this.options.apiEndpoint;

Expand All @@ -1633,6 +1688,19 @@ AmplitudeClient.prototype.sendEvents = function sendEvents() {
checksum: md5(Constants.API_VERSION + this.options.apiKey + events + uploadTime),
};

if (this.options.transport === Constants.TRANSPORT_BEACON) {
const success = navigator.sendBeacon(url, new URLSearchParams(data));

if (success) {
this.removeEvents(maxEventId, maxIdentifyId, 200, 'success');
if (this.options.saveEvents) {
this.saveEvents();
}
} else {
this._logErrorsOnEvents(maxEventId, maxIdentifyId, 0, '');
}
return;
}
var scope = this;
new Request(url, data, this.options.headers).send(function (status, response) {
scope._sending = false;
Expand Down
3 changes: 3 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,7 @@ export default {
UTM_CONTENT: 'utm_content',

ATTRIBUTION_EVENT: '[Amplitude] Attribution Captured',

TRANSPORT_HTTP: 'http',
TRANSPORT_BEACON: 'beacon',
};
2 changes: 1 addition & 1 deletion src/metadata-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ class MetadataStorage {
utils.log.info(`window.sessionStorage unavailable. Reason: "${e}"`);
}
}
return !!str
return !!str;
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import language from './language';
* @property {boolean} [logAttributionCapturedEvent=`false`] - If `true`, the SDK will log an Amplitude event anytime new attribution values are captured from the user. **Note: These events count towards your event volume.** Event name being logged: [Amplitude] Attribution Captured. Event Properties that can be logged: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`, `referrer`, `referring_domain`, `gclid`, `fbclid`. For UTM properties to be logged, `includeUtm` must be set to `true`. For the `referrer` and `referring_domain` properties to be logged, `includeReferrer` must be set to `true`. For the `gclid` property to be logged, `includeGclid` must be set to `true`. For the `fbclid` property to be logged, `includeFbclid` must be set to `true`.
* @property {boolean} [optOut=`false`] - Whether or not to disable tracking for the current user.
* @property {function} [onError=`() => {}`] - Function to call on error.
* @property {function} [onExitPage=`() => {}`] - Function called when the user exits the browser. Useful logging on page exit.
* @property {string} [platform=`Web`] - Platform device is running on. Defaults to `Web` (browser, including mobile browsers).
* @property {number} [savedMaxCount=`1000`] - Maximum number of events to save in localStorage. If more events are logged while offline, then old events are removed.
* @property {boolean} [saveEvents=`true`] - If `true`, saves events to localStorage and removes them upon successful upload. *Note: Without saving events, events may be lost if the user navigates to another page before the events are uploaded.*
Expand All @@ -35,6 +36,7 @@ import language from './language';
* @property {number} [sessionTimeout=`30*60*1000` (30 min)] - The time between logged events before a new session starts in milliseconds.
* @property {string[]} [storage=`''`] - Sets storage strategy. Options are 'cookies', 'localStorage', 'sessionStorage', or `none`. Will override `disableCookies` option
* @property {Object} [trackingOptions=`{ city: true, country: true, carrier: true, device_manufacturer: true, device_model: true, dma: true, ip_address: true, language: true, os_name: true, os_version: true, platform: true, region: true, version_name: true}`] - Type of data associated with a user.
* @property {string} [transport=`http`] - Network transport mechanism used to send events. Options are 'http' and 'beacon'.
* @property {boolean} [unsetParamsReferrerOnNewSession=`false`] - If `false`, the existing `referrer` and `utm_parameter` values will be carried through each new session. If set to `true`, the `referrer` and `utm_parameter` user properties, which include `referrer`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, and `utm_content`, will be set to `null` upon instantiating a new session. Note: This only works if `includeReferrer` or `includeUtm` is set to `true`.
* @property {string} [unsentKey=`amplitude_unsent`] - localStorage key that stores unsent events.
* @property {string} [unsentIdentifyKey=`amplitude_unsent_identify`] - localStorage key that stores unsent identifies.
Expand Down Expand Up @@ -64,6 +66,7 @@ export default {
logAttributionCapturedEvent: false,
optOut: false,
onError: () => {},
onExitPage: () => {},
platform: 'Web',
savedMaxCount: 1000,
saveEvents: true,
Expand All @@ -86,6 +89,7 @@ export default {
region: true,
version_name: true,
},
transport: Constants.TRANSPORT_HTTP,
unsetParamsReferrerOnNewSession: false,
unsentKey: 'amplitude_unsent',
unsentIdentifyKey: 'amplitude_unsent_identify',
Expand Down
18 changes: 18 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ const validateDeviceId = function validateDeviceId(deviceId) {
return true;
};

const validateTransport = function validateTransport(transport) {
if (!validateInput(transport, 'transport', 'string')) {
return false;
}

if (transport !== constants.TRANSPORT_HTTP && transport !== constants.TRANSPORT_BEACON) {
log.error(`transport value must be one of '${constants.TRANSPORT_BEACON}' or '${constants.TRANSPORT_HTTP}'`);
return false;
}

if (transport !== constants.TRANSPORT_HTTP && !navigator.sendBeacon) {
log.error(`browser does not support sendBeacon, so transport must be HTTP`);
return false;
}
return true;
};

// do some basic sanitization and type checking, also catch property dicts with more than 1000 key/value pairs
var validateProperties = function validateProperties(properties) {
var propsType = type(properties);
Expand Down Expand Up @@ -269,4 +286,5 @@ export default {
validateInput,
validateProperties,
validateDeviceId,
validateTransport,
};
49 changes: 49 additions & 0 deletions test/amplitude-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -4028,4 +4028,53 @@ describe('AmplitudeClient', function () {
assert.isNull(amplitude._metadataStorage.load());
});
});

describe('beacon logic', function () {
it('should set default transport correctly', function () {
amplitude.init(apiKey);
assert.equal(amplitude.options.transport, constants.TRANSPORT_HTTP);
});

it('should accept transport option correctly', function () {
amplitude.init(apiKey, null, { transport: constants.TRANSPORT_BEACON });
assert.equal(amplitude.options.transport, constants.TRANSPORT_BEACON);
});

it('should set transport correctly with setTransport', function () {
amplitude.init(apiKey);
amplitude.setTransport(constants.TRANSPORT_BEACON);
assert.equal(amplitude.options.transport, constants.TRANSPORT_BEACON);

amplitude.setTransport(constants.TRANSPORT_HTTP);
assert.equal(amplitude.options.transport, constants.TRANSPORT_HTTP);
});

it('should use sendBeacon when beacon transport is set', function () {
sandbox.stub(navigator, 'sendBeacon').returns(true);
const callback = sandbox.spy();
const errCallback = sandbox.spy();

amplitude.init(apiKey, null, { transport: constants.TRANSPORT_BEACON });
amplitude.logEvent('test event', {}, callback, errCallback);

assert.equal(navigator.sendBeacon.callCount, 1);
assert.equal(amplitude._unsentEvents.length, 0);
assert.isTrue(callback.calledOnce);
assert.isFalse(errCallback.calledOnce);
});

it('should not remove event from unsentEvents if beacon returns false', function () {
sandbox.stub(navigator, 'sendBeacon').returns(false);
const callback = sandbox.spy();
const errCallback = sandbox.spy();

amplitude.init(apiKey, null, { transport: constants.TRANSPORT_BEACON });
amplitude.logEvent('test event', {}, callback, errCallback);

assert.equal(navigator.sendBeacon.callCount, 1);
assert.equal(amplitude._unsentEvents.length, 1);
assert.isFalse(callback.calledOnce);
assert.isTrue(errCallback.calledOnce);
});
});
});

0 comments on commit 0517038

Please sign in to comment.