Skip to content

Commit 0517038

Browse files
ajhorstAJ Horst
and
AJ Horst
authored
feat: add sendBeacon support (#412)
* 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]>
1 parent f744fe7 commit 0517038

7 files changed

+151
-6
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@ amplitude-segment-snippet.min.js
1515
package-lock.json
1616
amplitude.umd.js
1717
amplitude.umd.min.js
18+
amplitude.native.js
19+
amplitude.nocompat.js
20+
amplitude.nocompat.min.js

src/amplitude-client.js

+73-5
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,31 @@ AmplitudeClient.prototype.init = function init(apiKey, opt_userId, opt_config, o
212212
if (type(opt_callback) === 'function') {
213213
opt_callback(this);
214214
}
215+
216+
const onExitPage = this.options.onExitPage;
217+
if (type(onExitPage) === 'function') {
218+
if (!this.pageHandlersAdded) {
219+
this.pageHandlersAdded = true;
220+
221+
const handleVisibilityChange = () => {
222+
const prevTransport = this.options.transport;
223+
this.setTransport(Constants.TRANSPORT_BEACON);
224+
onExitPage();
225+
this.setTransport(prevTransport);
226+
};
227+
228+
// Monitoring just page exits because that is the most requested feature for now
229+
// "If you're specifically trying to detect page unload events, the pagehide event is the best option."
230+
// https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
231+
window.addEventListener(
232+
'pagehide',
233+
() => {
234+
handleVisibilityChange();
235+
},
236+
false,
237+
);
238+
}
239+
}
215240
} catch (err) {
216241
utils.log.error(err);
217242
if (type(opt_config.onError) === 'function') {
@@ -334,7 +359,9 @@ var _parseConfig = function _parseConfig(options, config) {
334359

335360
var inputValue = config[key];
336361
var expectedType = type(options[key]);
337-
if (!utils.validateInput(inputValue, key + ' option', expectedType)) {
362+
if (key === 'transport' && !utils.validateTransport(inputValue)) {
363+
return;
364+
} else if (!utils.validateInput(inputValue, key + ' option', expectedType)) {
338365
return;
339366
}
340367
if (expectedType === 'boolean') {
@@ -511,6 +538,13 @@ AmplitudeClient.prototype._sendEventsIfReady = function _sendEventsIfReady() {
511538
return true;
512539
}
513540

541+
// if beacon transport is activated, send events immediately
542+
// because there is no way to retry them later
543+
if (this.options.transport === Constants.TRANSPORT_BEACON) {
544+
this.sendEvents();
545+
return true;
546+
}
547+
514548
// otherwise schedule an upload after 30s
515549
if (!this._updateScheduled) {
516550
// make sure we only schedule 1 upload
@@ -960,6 +994,25 @@ AmplitudeClient.prototype.setDeviceId = function setDeviceId(deviceId) {
960994
}
961995
};
962996

997+
/**
998+
* Sets the network transport type for events. Typically used to set to 'beacon'
999+
* on an end-of-lifecycle event handler such as `onpagehide` or `onvisibilitychange`
1000+
* @public
1001+
* @param {string} transport - transport mechanism to use for events. Must be one of `http` or `beacon`.
1002+
* @example amplitudeClient.setDeviceId('45f0954f-eb79-4463-ac8a-233a6f45a8f0');
1003+
*/
1004+
AmplitudeClient.prototype.setTransport = function setTransport(transport) {
1005+
if (this._shouldDeferCall()) {
1006+
return this._q.push(['setTransport'].concat(Array.prototype.slice.call(arguments, 0)));
1007+
}
1008+
1009+
if (!utils.validateTransport(transport)) {
1010+
return;
1011+
}
1012+
1013+
this.options.transport = transport;
1014+
};
1015+
9631016
/**
9641017
* Sets user properties for the current user.
9651018
* @public
@@ -1609,11 +1662,13 @@ AmplitudeClient.prototype.sendEvents = function sendEvents() {
16091662

16101663
// We only make one request at a time. sendEvents will be invoked again once
16111664
// the last request completes.
1612-
if (this._sending) {
1613-
return;
1665+
// beacon data is sent synchronously, so don't pause for it
1666+
if (this.options.transport !== Constants.TRANSPORT_BEACON) {
1667+
if (this._sending) {
1668+
return;
1669+
}
1670+
this._sending = true;
16141671
}
1615-
1616-
this._sending = true;
16171672
var protocol = this.options.forceHttps ? 'https' : 'https:' === window.location.protocol ? 'https' : 'http';
16181673
var url = protocol + '://' + this.options.apiEndpoint;
16191674

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

1691+
if (this.options.transport === Constants.TRANSPORT_BEACON) {
1692+
const success = navigator.sendBeacon(url, new URLSearchParams(data));
1693+
1694+
if (success) {
1695+
this.removeEvents(maxEventId, maxIdentifyId, 200, 'success');
1696+
if (this.options.saveEvents) {
1697+
this.saveEvents();
1698+
}
1699+
} else {
1700+
this._logErrorsOnEvents(maxEventId, maxIdentifyId, 0, '');
1701+
}
1702+
return;
1703+
}
16361704
var scope = this;
16371705
new Request(url, data, this.options.headers).send(function (status, response) {
16381706
scope._sending = false;

src/constants.js

+3
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,7 @@ export default {
5757
UTM_CONTENT: 'utm_content',
5858

5959
ATTRIBUTION_EVENT: '[Amplitude] Attribution Captured',
60+
61+
TRANSPORT_HTTP: 'http',
62+
TRANSPORT_BEACON: 'beacon',
6063
};

src/metadata-storage.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ class MetadataStorage {
193193
utils.log.info(`window.sessionStorage unavailable. Reason: "${e}"`);
194194
}
195195
}
196-
return !!str
196+
return !!str;
197197
}
198198
}
199199

src/options.js

+4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import language from './language';
2727
* @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`.
2828
* @property {boolean} [optOut=`false`] - Whether or not to disable tracking for the current user.
2929
* @property {function} [onError=`() => {}`] - Function to call on error.
30+
* @property {function} [onExitPage=`() => {}`] - Function called when the user exits the browser. Useful logging on page exit.
3031
* @property {string} [platform=`Web`] - Platform device is running on. Defaults to `Web` (browser, including mobile browsers).
3132
* @property {number} [savedMaxCount=`1000`] - Maximum number of events to save in localStorage. If more events are logged while offline, then old events are removed.
3233
* @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.*
@@ -35,6 +36,7 @@ import language from './language';
3536
* @property {number} [sessionTimeout=`30*60*1000` (30 min)] - The time between logged events before a new session starts in milliseconds.
3637
* @property {string[]} [storage=`''`] - Sets storage strategy. Options are 'cookies', 'localStorage', 'sessionStorage', or `none`. Will override `disableCookies` option
3738
* @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.
39+
* @property {string} [transport=`http`] - Network transport mechanism used to send events. Options are 'http' and 'beacon'.
3840
* @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`.
3941
* @property {string} [unsentKey=`amplitude_unsent`] - localStorage key that stores unsent events.
4042
* @property {string} [unsentIdentifyKey=`amplitude_unsent_identify`] - localStorage key that stores unsent identifies.
@@ -64,6 +66,7 @@ export default {
6466
logAttributionCapturedEvent: false,
6567
optOut: false,
6668
onError: () => {},
69+
onExitPage: () => {},
6770
platform: 'Web',
6871
savedMaxCount: 1000,
6972
saveEvents: true,
@@ -86,6 +89,7 @@ export default {
8689
region: true,
8790
version_name: true,
8891
},
92+
transport: Constants.TRANSPORT_HTTP,
8993
unsetParamsReferrerOnNewSession: false,
9094
unsentKey: 'amplitude_unsent',
9195
unsentIdentifyKey: 'amplitude_unsent_identify',

src/utils.js

+18
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,23 @@ const validateDeviceId = function validateDeviceId(deviceId) {
108108
return true;
109109
};
110110

111+
const validateTransport = function validateTransport(transport) {
112+
if (!validateInput(transport, 'transport', 'string')) {
113+
return false;
114+
}
115+
116+
if (transport !== constants.TRANSPORT_HTTP && transport !== constants.TRANSPORT_BEACON) {
117+
log.error(`transport value must be one of '${constants.TRANSPORT_BEACON}' or '${constants.TRANSPORT_HTTP}'`);
118+
return false;
119+
}
120+
121+
if (transport !== constants.TRANSPORT_HTTP && !navigator.sendBeacon) {
122+
log.error(`browser does not support sendBeacon, so transport must be HTTP`);
123+
return false;
124+
}
125+
return true;
126+
};
127+
111128
// do some basic sanitization and type checking, also catch property dicts with more than 1000 key/value pairs
112129
var validateProperties = function validateProperties(properties) {
113130
var propsType = type(properties);
@@ -269,4 +286,5 @@ export default {
269286
validateInput,
270287
validateProperties,
271288
validateDeviceId,
289+
validateTransport,
272290
};

test/amplitude-client.js

+49
Original file line numberDiff line numberDiff line change
@@ -4028,4 +4028,53 @@ describe('AmplitudeClient', function () {
40284028
assert.isNull(amplitude._metadataStorage.load());
40294029
});
40304030
});
4031+
4032+
describe('beacon logic', function () {
4033+
it('should set default transport correctly', function () {
4034+
amplitude.init(apiKey);
4035+
assert.equal(amplitude.options.transport, constants.TRANSPORT_HTTP);
4036+
});
4037+
4038+
it('should accept transport option correctly', function () {
4039+
amplitude.init(apiKey, null, { transport: constants.TRANSPORT_BEACON });
4040+
assert.equal(amplitude.options.transport, constants.TRANSPORT_BEACON);
4041+
});
4042+
4043+
it('should set transport correctly with setTransport', function () {
4044+
amplitude.init(apiKey);
4045+
amplitude.setTransport(constants.TRANSPORT_BEACON);
4046+
assert.equal(amplitude.options.transport, constants.TRANSPORT_BEACON);
4047+
4048+
amplitude.setTransport(constants.TRANSPORT_HTTP);
4049+
assert.equal(amplitude.options.transport, constants.TRANSPORT_HTTP);
4050+
});
4051+
4052+
it('should use sendBeacon when beacon transport is set', function () {
4053+
sandbox.stub(navigator, 'sendBeacon').returns(true);
4054+
const callback = sandbox.spy();
4055+
const errCallback = sandbox.spy();
4056+
4057+
amplitude.init(apiKey, null, { transport: constants.TRANSPORT_BEACON });
4058+
amplitude.logEvent('test event', {}, callback, errCallback);
4059+
4060+
assert.equal(navigator.sendBeacon.callCount, 1);
4061+
assert.equal(amplitude._unsentEvents.length, 0);
4062+
assert.isTrue(callback.calledOnce);
4063+
assert.isFalse(errCallback.calledOnce);
4064+
});
4065+
4066+
it('should not remove event from unsentEvents if beacon returns false', function () {
4067+
sandbox.stub(navigator, 'sendBeacon').returns(false);
4068+
const callback = sandbox.spy();
4069+
const errCallback = sandbox.spy();
4070+
4071+
amplitude.init(apiKey, null, { transport: constants.TRANSPORT_BEACON });
4072+
amplitude.logEvent('test event', {}, callback, errCallback);
4073+
4074+
assert.equal(navigator.sendBeacon.callCount, 1);
4075+
assert.equal(amplitude._unsentEvents.length, 1);
4076+
assert.isFalse(callback.calledOnce);
4077+
assert.isTrue(errCallback.calledOnce);
4078+
});
4079+
});
40314080
});

0 commit comments

Comments
 (0)