Skip to content

Commit 664170e

Browse files
author
Krishna Rajendran
authored
More compact cookie storage with the option to use local storage (amplitude#244)
Cookies had this fancy abstraction to mimic local storage. This led to excessively large cookies. Rather than storing a large JSON serialized version of the cookie data we use positional values. Base64 encoding also led to considerable cookie bloat. Instead we only Base64 encode the user id field and assume other fields in the cookie are safe. You can also forgo using cookie storage altogether with the disableCookie option. This prevents tracking users across different subdomains of your site.
1 parent 34d978f commit 664170e

9 files changed

+289
-449
lines changed

src/amplitude-client.js

+69-82
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Constants from './constants';
22
import cookieStorage from './cookiestorage';
3+
import MetadataStorage from './metaDataStorage';
34
import getUtmData from './utm';
45
import Identify from './identify';
56
import localStorage from './localstorage'; // jshint ignore:line
@@ -32,7 +33,6 @@ if (BUILD_COMPAT_REACT_NATIVE) {
3233
*/
3334
var AmplitudeClient = function AmplitudeClient(instanceName) {
3435
this._instanceName = utils.isEmptyString(instanceName) ? Constants.DEFAULT_INSTANCE : instanceName.toLowerCase();
35-
this._legacyStorageSuffix = this._instanceName === Constants.DEFAULT_INSTANCE ? '' : '_' + this._instanceName;
3636
this._unsentEvents = [];
3737
this._unsentIdentifys = [];
3838
this._ua = new UAParser(navigator.userAgent).getResult();
@@ -76,37 +76,63 @@ AmplitudeClient.prototype.init = function init(apiKey, opt_userId, opt_config, o
7676
}
7777

7878
try {
79-
this.options.apiKey = apiKey;
80-
this._storageSuffix = '_' + apiKey + this._legacyStorageSuffix;
79+
_parseConfig(this.options, opt_config);
8180

82-
var hasExistingCookie = !!this.cookieStorage.get(this.options.cookieName + this._storageSuffix);
83-
if (opt_config && opt_config.deferInitialization && !hasExistingCookie) {
84-
this._deferInitialization(apiKey, opt_userId, opt_config, opt_callback);
85-
return;
81+
if (this.options.cookieName !== DEFAULT_OPTIONS.cookieName) {
82+
utils.log.warn('The cookieName option is deprecated. We will be ignoring it for newer cookies');
8683
}
8784

88-
_parseConfig(this.options, opt_config);
85+
this.options.apiKey = apiKey;
86+
this._storageSuffix = '_' + apiKey + (this._instanceName === Constants.DEFAULT_INSTANCE ? '' : '_' + this._instanceName);
87+
this._storageSuffixV5 = apiKey.slice(0,6);
8988

90-
if (type(this.options.logLevel) === 'string') {
91-
utils.setLogLevel(this.options.logLevel);
92-
}
89+
this._oldCookiename = this.options.cookieName + this._storageSuffix;
90+
this._unsentKey = this.options.unsentKey + this._storageSuffix;
91+
this._unsentIdentifyKey = this.options.unsentIdentifyKey + this._storageSuffix;
9392

94-
var trackingOptions = _generateApiPropertiesTrackingConfig(this);
95-
this._apiPropertiesTrackingOptions = Object.keys(trackingOptions).length > 0 ? {tracking_options: trackingOptions} : {};
93+
this._cookieName = Constants.COOKIE_PREFIX + '_' + this._storageSuffixV5;
9694

9795
this.cookieStorage.options({
9896
expirationDays: this.options.cookieExpiration,
9997
domain: this.options.domain,
10098
secure: this.options.secureCookie,
10199
sameSite: this.options.sameSiteCookie
102100
});
101+
102+
this._metadataStorage = new MetadataStorage({
103+
storageKey: this._cookieName,
104+
disableCookies: this.options.disableCookies,
105+
expirationDays: this.options.cookieExpiration,
106+
domain: this.options.domain,
107+
secure: this.options.secureCookie,
108+
sameSite: this.options.sameSiteCookie
109+
});
110+
111+
const hasOldCookie = !!this.cookieStorage.get(this._oldCookiename);
112+
const hasNewCookie = !!this._metadataStorage.load();
113+
this._useOldCookie = (!hasNewCookie && hasOldCookie) && !this.options.cookieForceUpgrade;
114+
const hasCookie = hasNewCookie || hasOldCookie;
103115
this.options.domain = this.cookieStorage.options().domain;
104116

105-
if (!BUILD_COMPAT_REACT_NATIVE) {
106-
if (this._instanceName === Constants.DEFAULT_INSTANCE) {
117+
if (this.options.deferInitialization && !hasCookie) {
118+
this._deferInitialization(apiKey, opt_userId, opt_config, opt_callback);
119+
return;
120+
}
121+
122+
if (type(this.options.logLevel) === 'string') {
123+
utils.setLogLevel(this.options.logLevel);
124+
}
125+
126+
var trackingOptions = _generateApiPropertiesTrackingConfig(this);
127+
this._apiPropertiesTrackingOptions = Object.keys(trackingOptions).length > 0 ? {tracking_options: trackingOptions} : {};
128+
129+
if (this.options.cookieForceUpgrade && hasOldCookie) {
130+
if (!hasNewCookie) {
107131
_upgradeCookieData(this);
108132
}
133+
this.cookieStorage.remove(this._oldCookiename);
109134
}
135+
110136
_loadCookieData(this);
111137
this._pendingReadStorage = true;
112138

@@ -508,75 +534,31 @@ AmplitudeClient.prototype._setInStorage = function _setInStorage(storage, key, v
508534
storage.setItem(key + this._storageSuffix, value);
509535
};
510536

511-
var _upgradeCookieData = function _upgradeCookieData(scope) {
512-
// skip if already migrated to 4.10+
513-
var cookieData = scope.cookieStorage.get(scope.options.cookieName + scope._storageSuffix);
514-
if (type(cookieData) === 'object') {
515-
return;
516-
}
517-
// skip if already migrated to 2.70+
518-
cookieData = scope.cookieStorage.get(scope.options.cookieName + scope._legacyStorageSuffix);
519-
if (type(cookieData) === 'object' && cookieData.deviceId && cookieData.sessionId && cookieData.lastEventTime) {
520-
return;
521-
}
522-
523-
var _getAndRemoveFromLocalStorage = function _getAndRemoveFromLocalStorage(key) {
524-
var value = localStorage.getItem(key);
525-
localStorage.removeItem(key);
526-
return value;
527-
};
528-
529-
// in v2.6.0, deviceId, userId, optOut was migrated to localStorage with keys + first 6 char of apiKey
530-
var apiKeySuffix = (type(scope.options.apiKey) === 'string' && ('_' + scope.options.apiKey.slice(0, 6))) || '';
531-
var localStorageDeviceId = _getAndRemoveFromLocalStorage(Constants.DEVICE_ID + apiKeySuffix);
532-
var localStorageUserId = _getAndRemoveFromLocalStorage(Constants.USER_ID + apiKeySuffix);
533-
var localStorageOptOut = _getAndRemoveFromLocalStorage(Constants.OPT_OUT + apiKeySuffix);
534-
if (localStorageOptOut !== null && localStorageOptOut !== undefined) {
535-
localStorageOptOut = String(localStorageOptOut) === 'true'; // convert to boolean
536-
}
537-
538-
// pre-v2.7.0 event and session meta-data was stored in localStorage. move to cookie for sub-domain support
539-
var localStorageSessionId = parseInt(_getAndRemoveFromLocalStorage(Constants.SESSION_ID));
540-
var localStorageLastEventTime = parseInt(_getAndRemoveFromLocalStorage(Constants.LAST_EVENT_TIME));
541-
var localStorageEventId = parseInt(_getAndRemoveFromLocalStorage(Constants.LAST_EVENT_ID));
542-
var localStorageIdentifyId = parseInt(_getAndRemoveFromLocalStorage(Constants.LAST_IDENTIFY_ID));
543-
var localStorageSequenceNumber = parseInt(_getAndRemoveFromLocalStorage(Constants.LAST_SEQUENCE_NUMBER));
544-
545-
var _getFromCookie = function _getFromCookie(key) {
546-
return type(cookieData) === 'object' && cookieData[key];
547-
};
548-
scope.options.deviceId = _getFromCookie('deviceId') || localStorageDeviceId;
549-
scope.options.userId = _getFromCookie('userId') || localStorageUserId;
550-
scope._sessionId = _getFromCookie('sessionId') || localStorageSessionId || scope._sessionId;
551-
scope._lastEventTime = _getFromCookie('lastEventTime') || localStorageLastEventTime || scope._lastEventTime;
552-
scope._eventId = _getFromCookie('eventId') || localStorageEventId || scope._eventId;
553-
scope._identifyId = _getFromCookie('identifyId') || localStorageIdentifyId || scope._identifyId;
554-
scope._sequenceNumber = _getFromCookie('sequenceNumber') || localStorageSequenceNumber || scope._sequenceNumber;
555-
556-
// optOut is a little trickier since it is a boolean
557-
scope.options.optOut = localStorageOptOut || false;
558-
if (cookieData && cookieData.optOut !== undefined && cookieData.optOut !== null) {
559-
scope.options.optOut = String(cookieData.optOut) === 'true';
560-
}
561-
562-
_saveCookieData(scope);
563-
};
564-
565537
/**
566538
* Fetches deviceId, userId, event meta data from amplitude cookie
567539
* @private
568540
*/
569541
var _loadCookieData = function _loadCookieData(scope) {
570-
var cookieData = scope.cookieStorage.get(scope.options.cookieName + scope._storageSuffix);
542+
if (!scope._useOldCookie) {
543+
const props = scope._metadataStorage.load();
544+
if (type(props) === 'object') {
545+
_loadCookieDataProps(scope, props);
546+
}
547+
return;
548+
}
571549

550+
var cookieData = scope.cookieStorage.get(scope._oldCookiename);
572551
if (type(cookieData) === 'object') {
573552
_loadCookieDataProps(scope, cookieData);
574-
} else {
575-
var legacyCookieData = scope.cookieStorage.get(scope.options.cookieName + scope._legacyStorageSuffix);
576-
if (type(legacyCookieData) === 'object') {
577-
scope.cookieStorage.remove(scope.options.cookieName + scope._legacyStorageSuffix);
578-
_loadCookieDataProps(scope, legacyCookieData);
579-
}
553+
return;
554+
}
555+
};
556+
557+
const _upgradeCookieData = (scope) => {
558+
var cookieData = scope.cookieStorage.get(scope._oldCookiename);
559+
if (type(cookieData) === 'object') {
560+
_loadCookieDataProps(scope, cookieData);
561+
_saveCookieData(scope);
580562
}
581563
};
582564

@@ -594,19 +576,19 @@ var _loadCookieDataProps = function _loadCookieDataProps(scope, cookieData) {
594576
}
595577
}
596578
if (cookieData.sessionId) {
597-
scope._sessionId = parseInt(cookieData.sessionId);
579+
scope._sessionId = parseInt(cookieData.sessionId, 10);
598580
}
599581
if (cookieData.lastEventTime) {
600-
scope._lastEventTime = parseInt(cookieData.lastEventTime);
582+
scope._lastEventTime = parseInt(cookieData.lastEventTime, 10);
601583
}
602584
if (cookieData.eventId) {
603-
scope._eventId = parseInt(cookieData.eventId);
585+
scope._eventId = parseInt(cookieData.eventId, 10);
604586
}
605587
if (cookieData.identifyId) {
606-
scope._identifyId = parseInt(cookieData.identifyId);
588+
scope._identifyId = parseInt(cookieData.identifyId, 10);
607589
}
608590
if (cookieData.sequenceNumber) {
609-
scope._sequenceNumber = parseInt(cookieData.sequenceNumber);
591+
scope._sequenceNumber = parseInt(cookieData.sequenceNumber, 10);
610592
}
611593
};
612594

@@ -628,7 +610,12 @@ var _saveCookieData = function _saveCookieData(scope) {
628610
if (AsyncStorage) {
629611
AsyncStorage.setItem(scope._storageSuffix, JSON.stringify(cookieData));
630612
}
631-
scope.cookieStorage.set(scope.options.cookieName + scope._storageSuffix, cookieData);
613+
614+
if (scope._useOldCookie) {
615+
scope.cookieStorage.set(scope.options.cookieName + scope._storageSuffix, cookieData);
616+
} else {
617+
scope._metadataStorage.save(cookieData);
618+
}
632619
};
633620

634621
/**

src/constants.js

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export default {
1919
USER_ID: 'amplitude_userId',
2020

2121
COOKIE_TEST: 'amplitude_cookie_test',
22+
COOKIE_PREFIX: "amp",
2223

2324
// revenue keys
2425
REVENUE_EVENT: 'revenue_amount',

src/cookie.js

+45-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import Base64 from './base64';
6+
import Constants from './constants';
67
import utils from './utils';
78
import getLocation from './get-location';
89
import baseCookie from './base-cookie';
@@ -27,7 +28,12 @@ const getHost = (url) => {
2728
return a.hostname || location.hostname;
2829
};
2930

31+
let _topDomain = '';
32+
3033
const topDomain = (url) => {
34+
if (_topDomain) {
35+
return topDomain;
36+
}
3137
const host = getHost(url);
3238
const parts = host.split('.');
3339
const last = parts[parts.length - 1];
@@ -121,6 +127,20 @@ var set = function(name, value) {
121127
}
122128
};
123129

130+
var setRaw = function(name, value) {
131+
try {
132+
baseCookie.set(_domainSpecific(name), value, _options);
133+
return true;
134+
} catch (e) {
135+
return false;
136+
}
137+
};
138+
139+
var getRaw = function(name) {
140+
var nameEq = _domainSpecific(name) + '=';
141+
return baseCookie.get(nameEq);
142+
};
143+
124144

125145
var remove = function(name) {
126146
try {
@@ -131,10 +151,34 @@ var remove = function(name) {
131151
}
132152
};
133153

154+
let _areCookiesEnabled = null;
155+
156+
// test that cookies are enabled - navigator.cookiesEnabled yields false positives in IE, need to test directly
157+
const areCookiesEnabled = () => {
158+
if (_areCookiesEnabled !== null) {
159+
return _areCookiesEnabled;
160+
}
161+
var uid = String(new Date());
162+
var result;
163+
try {
164+
set(Constants.COOKIE_TEST, uid);
165+
_areCookiesEnabled = get(Constants.COOKIE_TEST) === uid;
166+
remove(Constants.COOKIE_TEST);
167+
return result;
168+
} catch (e) {
169+
// cookies are not enabled
170+
}
171+
return false;
172+
};
173+
134174
export default {
135175
reset,
136176
options,
177+
topDomain,
137178
get,
138179
set,
139-
remove
180+
remove,
181+
areCookiesEnabled,
182+
setRaw,
183+
getRaw
140184
};

src/metadataStorage.js

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Persist SDK event metadata
3+
* Uses cookie if available, otherwise fallback to localstorage.
4+
*/
5+
6+
import Base64 from './base64';
7+
import Cookie from './cookie';
8+
import baseCookie from './base-cookie';
9+
import localStorage from './localstorage'; // jshint ignore:line
10+
11+
class MetadataStorage {
12+
constructor({storageKey, disableCookies, domain, secure, sameSite, expirationDays}) {
13+
this.storageKey = storageKey;
14+
this.disableCookieStorage = !Cookie.areCookiesEnabled() || disableCookies;
15+
this.domain = domain;
16+
this.secure = secure;
17+
this.sameSite = sameSite;
18+
this.expirationDays = expirationDays;
19+
this.topDomain = domain || Cookie.topDomain();
20+
}
21+
22+
getCookieStorageKey() {
23+
return `${this.storageKey}${this.domain ? `_${this.domain}` : ''}`;
24+
}
25+
26+
save({ deviceId, userId, optOut, sessionId, lastEventTime, eventId, identifyId, sequenceNumber }) {
27+
// do not change the order of these items
28+
const value = `${deviceId}.${Base64.encode(userId || '')}.${optOut ? '1' : ''}.${sessionId}.${lastEventTime}.${eventId}.${identifyId}.${sequenceNumber}`;
29+
30+
if (this.disableCookieStorage) {
31+
localStorage.setItem(this.storageKey, value);
32+
} else {
33+
baseCookie.set(
34+
this.getCookieStorageKey(),
35+
value,
36+
{ domain: this.topDomain, secure: this.secure, sameSite: this.sameSite, expirationDays: this.expirationDays }
37+
);
38+
}
39+
}
40+
41+
load() {
42+
let str;
43+
if (!this.disableCookieStorage) {
44+
str = baseCookie.get(this.getCookieStorageKey() + '=');
45+
}
46+
if (!str) {
47+
str = localStorage.getItem(this.storageKey);
48+
}
49+
50+
if (!str) {
51+
return null;
52+
}
53+
54+
const values = str.split('.');
55+
56+
let userId = null;
57+
if (values[1]) {
58+
try {
59+
userId = Base64.decode(values[1]);
60+
} catch (e) {
61+
userId = null;
62+
}
63+
}
64+
65+
return {
66+
deviceId: values[0],
67+
userId,
68+
optOut: values[2] === '1',
69+
sessionId: parseInt(values[3], 10),
70+
lastEventTime: parseInt(values[4], 10),
71+
eventId: parseInt(values[5], 10),
72+
identifyId: parseInt(values[6], 10),
73+
sequenceNumber: parseInt(values[7], 10)
74+
};
75+
}
76+
}
77+
78+
export default MetadataStorage;

0 commit comments

Comments
 (0)