diff --git a/.gitignore b/.gitignore index abdd7077..a475eb8f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/src/amplitude-client.js b/src/amplitude-client.js index d6e34f16..608e1e6e 100644 --- a/src/amplitude-client.js +++ b/src/amplitude-client.js @@ -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') { @@ -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') { @@ -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 @@ -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 @@ -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; @@ -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; diff --git a/src/constants.js b/src/constants.js index dd4f8cab..86bdb9da 100644 --- a/src/constants.js +++ b/src/constants.js @@ -57,4 +57,7 @@ export default { UTM_CONTENT: 'utm_content', ATTRIBUTION_EVENT: '[Amplitude] Attribution Captured', + + TRANSPORT_HTTP: 'http', + TRANSPORT_BEACON: 'beacon', }; diff --git a/src/metadata-storage.js b/src/metadata-storage.js index 67a0ba5b..5bfd116b 100644 --- a/src/metadata-storage.js +++ b/src/metadata-storage.js @@ -193,7 +193,7 @@ class MetadataStorage { utils.log.info(`window.sessionStorage unavailable. Reason: "${e}"`); } } - return !!str + return !!str; } } diff --git a/src/options.js b/src/options.js index a28ef392..f3ccbcfb 100644 --- a/src/options.js +++ b/src/options.js @@ -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.* @@ -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. @@ -64,6 +66,7 @@ export default { logAttributionCapturedEvent: false, optOut: false, onError: () => {}, + onExitPage: () => {}, platform: 'Web', savedMaxCount: 1000, saveEvents: true, @@ -86,6 +89,7 @@ export default { region: true, version_name: true, }, + transport: Constants.TRANSPORT_HTTP, unsetParamsReferrerOnNewSession: false, unsentKey: 'amplitude_unsent', unsentIdentifyKey: 'amplitude_unsent_identify', diff --git a/src/utils.js b/src/utils.js index eab560f4..aee67369 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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); @@ -269,4 +286,5 @@ export default { validateInput, validateProperties, validateDeviceId, + validateTransport, }; diff --git a/test/amplitude-client.js b/test/amplitude-client.js index aaacabd3..1d2dc474 100644 --- a/test/amplitude-client.js +++ b/test/amplitude-client.js @@ -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); + }); + }); });