Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add sendBeacon support #412

Merged
merged 12 commits into from
Aug 13, 2021
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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts on hooking the current object with onExitPage(this) allowing users to access the client earlier?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in, so they can write the callback on a line before initializing the client with it? The way I was testing it, as long as the amplitude object is in scope within the file, it doesn't need to be initialized until onExitPage is called. So the following is fine:

const onExitPage = () => {
      amplitude.getInstance().logEvent('logging onExitPage');
    };

    amplitude.getInstance().init('API_KEY', userID, { onExitPage });

But if there's another benefit you're thinking of by making the client an argument, it would be an easy change to make

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might make it easier for people using the snippet to use the full client obj (incl. "private" methods and the full list of options post-init to construct event properties) but you are right that everything publicly supported is available.

I don't feel too strongly about this though so we can not do this and see what people actually want as arguments to this option!

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be too much, but would be good to replace a lot of these with a small descriptive helper function like doesTransportSupportSaveEvents

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried adding helper functions, but since there are only two transports now, they didn't add much descriptivity compared to checking the value itself. If there end up being more than two, then definitely there should be helper functions to categorize them like this

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this checked on initialization? In case it comes in bad

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not but it should be, good call

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);
});
});
});