Skip to content

Commit baec2a9

Browse files
Make cookie writes async by default to improve tracker performance (#1340)
1 parent bba7aa4 commit baec2a9

File tree

18 files changed

+509
-14
lines changed

18 files changed

+509
-14
lines changed

api-docs/docs/browser-tracker/browser-tracker.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ export type TrackerConfiguration = {
541541
plugins?: Array<BrowserPlugin>;
542542
onSessionUpdateCallback?: (updatedSession: ClientSession) => void;
543543
preservePageViewIdForUrl?: PreservePageViewIdForUrl;
544+
synchronousCookieWrite?: boolean;
544545
} & EmitterConfigurationBase & LocalStorageEventStoreConfigurationBase;
545546

546547
// @public

api-docs/docs/browser-tracker/markdown/browser-tracker.trackerconfiguration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type TrackerConfiguration = {
3030
plugins?: Array<BrowserPlugin>;
3131
onSessionUpdateCallback?: (updatedSession: ClientSession) => void;
3232
preservePageViewIdForUrl?: PreservePageViewIdForUrl;
33+
synchronousCookieWrite?: boolean;
3334
} & EmitterConfigurationBase & LocalStorageEventStoreConfigurationBase;
3435
```
3536

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@snowplow/browser-tracker-core",
5+
"comment": "Make cookie writes async by default to improve tracker performance (#1340)",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@snowplow/browser-tracker-core"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@snowplow/browser-tracker",
5+
"comment": "Make cookie writes async by default to improve tracker performance (#1340)",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@snowplow/browser-tracker"
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@snowplow/javascript-tracker",
5+
"comment": "Make cookie writes async by default to improve tracker performance (#1340)",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@snowplow/javascript-tracker"
10+
}

libraries/browser-tracker-core/src/helpers/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,10 @@ export function findRootDomain(sameSite: string, secure: boolean) {
254254
cookie(cookieName, cookieValue, 0, '/', currentDomain, sameSite, secure);
255255
if (cookie(cookieName) === cookieValue) {
256256
// Clean up created cookie(s)
257-
deleteCookie(cookieName, currentDomain, sameSite, secure);
257+
deleteCookie(cookieName, '/', currentDomain, sameSite, secure);
258258
const cookieNames = getCookiesWithPrefix(cookiePrefix);
259259
for (let i = 0; i < cookieNames.length; i++) {
260-
deleteCookie(cookieNames[i], currentDomain, sameSite, secure);
260+
deleteCookie(cookieNames[i], '/', currentDomain, sameSite, secure);
261261
}
262262

263263
return currentDomain;
@@ -290,8 +290,8 @@ export function isValueInArray<T>(val: T, array: T[]) {
290290
* @param cookieName - The name of the cookie to delete
291291
* @param domainName - The domain the cookie is in
292292
*/
293-
export function deleteCookie(cookieName: string, domainName?: string, sameSite?: string, secure?: boolean) {
294-
cookie(cookieName, '', -1, '/', domainName, sameSite, secure);
293+
export function deleteCookie(cookieName: string, path?: string, domainName?: string, sameSite?: string, secure?: boolean) {
294+
cookie(cookieName, '', -1, path, domainName, sameSite, secure);
295295
}
296296

297297
/**

libraries/browser-tracker-core/src/snowplow.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { LOG } from '@snowplow/tracker-core';
3232
import { SharedState } from './state';
3333
import { Tracker } from './tracker';
3434
import { BrowserTracker, TrackerConfiguration } from './tracker/types';
35+
import { asyncCookieStorage } from './tracker/cookie_storage';
3536

3637
const namedTrackers: Record<string, BrowserTracker> = {};
3738

@@ -151,3 +152,12 @@ function getTrackersFromCollection(
151152
}
152153
return trackers;
153154
}
155+
156+
/**
157+
* Write all pending cookies to the browser.
158+
* Useful if you track events just before the page is unloaded.
159+
* This call is not necessary if `synchronousCookieWrite` is set to `true`.
160+
*/
161+
export function flushPendingCookies() {
162+
asyncCookieStorage.flush();
163+
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { cookie, deleteCookie } from '../helpers';
2+
3+
4+
/**
5+
* Cookie storage interface for reading and writing cookies.
6+
*/
7+
export interface CookieStorage {
8+
/**
9+
* Get the value of a cookie
10+
*
11+
* @param name - The name of the cookie
12+
* @returns The cookie value
13+
*/
14+
getCookie(name: string): string;
15+
16+
/**
17+
* Set a cookie
18+
*
19+
* @param name - The cookie name (required)
20+
* @param value - The cookie value
21+
* @param ttl - The cookie Time To Live (seconds)
22+
* @param path - The cookies path
23+
* @param domain - The cookies domain
24+
* @param samesite - The cookies samesite attribute
25+
* @param secure - Boolean to specify if cookie should be secure
26+
* @returns true if the cookie was set, false otherwise
27+
*/
28+
setCookie(
29+
name: string,
30+
value?: string,
31+
ttl?: number,
32+
path?: string,
33+
domain?: string,
34+
samesite?: string,
35+
secure?: boolean
36+
): boolean;
37+
38+
/**
39+
* Delete a cookie
40+
*
41+
* @param name - The cookie name
42+
* @param domainName - The cookie domain name
43+
* @param sameSite - The cookie same site attribute
44+
* @param secure - Boolean to specify if cookie should be secure
45+
*/
46+
deleteCookie(name: string, path?: string, domainName?: string, sameSite?: string, secure?: boolean): void;
47+
}
48+
49+
export interface AsyncCookieStorage extends CookieStorage {
50+
/**
51+
* Clear the cookie storage cache (does not delete any cookies)
52+
*/
53+
clearCache(): void;
54+
55+
/**
56+
* Write all pending cookies.
57+
*/
58+
flush(): void;
59+
}
60+
61+
interface Cookie {
62+
getValue: () => string;
63+
setValue: (value?: string, ttl?: number, path?: string, domain?: string, samesite?: string, secure?: boolean) => boolean;
64+
deleteValue: (path?: string, domainName?: string, sameSite?: string, secure?: boolean) => void;
65+
flush: () => void;
66+
}
67+
68+
function newCookie(name: string): Cookie {
69+
let flushTimer: ReturnType<typeof setTimeout> | undefined;
70+
let lastSetValueArgs: Parameters<typeof setValue> | undefined;
71+
let cacheExpireAt: Date | undefined;
72+
let flushed = true;
73+
const flushTimeout = 10; // milliseconds
74+
const maxCacheTtl = 0.05; // seconds
75+
76+
function getValue(): string {
77+
// Note: we can't cache the cookie value as we don't know the expiration date
78+
if (lastSetValueArgs && (!cacheExpireAt || cacheExpireAt > new Date())) {
79+
return lastSetValueArgs[0] ?? cookie(name);
80+
}
81+
return cookie(name);
82+
}
83+
84+
function setValue(value?: string, ttl?: number, path?: string, domain?: string, samesite?: string, secure?: boolean): boolean {
85+
lastSetValueArgs = [value, ttl, path, domain, samesite, secure];
86+
flushed = false;
87+
88+
// throttle setting the cookie
89+
if (flushTimer === undefined) {
90+
flushTimer = setTimeout(() => {
91+
flushTimer = undefined;
92+
flush();
93+
}, flushTimeout);
94+
}
95+
96+
cacheExpireAt = new Date(Date.now() + Math.min(maxCacheTtl, ttl ?? maxCacheTtl) * 1000);
97+
return true;
98+
}
99+
100+
function deleteValue(path?: string, domainName?: string, sameSite?: string, secure?: boolean): void {
101+
lastSetValueArgs = undefined;
102+
flushed = true;
103+
104+
// cancel setting the cookie
105+
if (flushTimer !== undefined) {
106+
clearTimeout(flushTimer);
107+
flushTimer = undefined;
108+
}
109+
110+
deleteCookie(name, path, domainName, sameSite, secure);
111+
}
112+
113+
function flush(): void {
114+
if (flushTimer !== undefined) {
115+
clearTimeout(flushTimer);
116+
flushTimer = undefined;
117+
}
118+
119+
if (flushed) {
120+
return;
121+
}
122+
flushed = true;
123+
124+
if (lastSetValueArgs !== undefined) {
125+
const [value, ttl, path, domain, samesite, secure] = lastSetValueArgs;
126+
cookie(name, value, ttl, path, domain, samesite, secure);
127+
}
128+
}
129+
130+
return {
131+
getValue,
132+
setValue,
133+
deleteValue,
134+
flush,
135+
};
136+
}
137+
138+
/**
139+
* Create a new async cookie storage
140+
*
141+
* @returns A new cookie storage
142+
*/
143+
export function newCookieStorage(): AsyncCookieStorage {
144+
let cache: Record<string, Cookie> = {};
145+
146+
function getOrInitCookie(name: string): Cookie {
147+
if (!cache[name]) {
148+
cache[name] = newCookie(name);
149+
}
150+
return cache[name];
151+
}
152+
153+
function getCookie(name: string): string {
154+
return getOrInitCookie(name).getValue();
155+
}
156+
157+
function setCookie(
158+
name: string,
159+
value?: string,
160+
ttl?: number,
161+
path?: string,
162+
domain?: string,
163+
samesite?: string,
164+
secure?: boolean
165+
): boolean {
166+
return getOrInitCookie(name).setValue(value, ttl, path, domain, samesite, secure);
167+
}
168+
169+
function deleteCookie(name: string, path?: string, domainName?: string, sameSite?: string, secure?: boolean): void {
170+
getOrInitCookie(name).deleteValue(path, domainName, sameSite, secure);
171+
}
172+
173+
function clearCache(): void {
174+
cache = {};
175+
}
176+
177+
function flush(): void {
178+
for (const cookie of Object.values(cache)) {
179+
cookie.flush();
180+
}
181+
}
182+
183+
return {
184+
getCookie,
185+
setCookie,
186+
deleteCookie,
187+
clearCache,
188+
flush,
189+
};
190+
}
191+
192+
/**
193+
* Cookie storage instance with asynchronous cookie writes
194+
*/
195+
export const asyncCookieStorage = newCookieStorage();
196+
197+
/**
198+
* Cookie storage instance with synchronous cookie writes
199+
*/
200+
export const syncCookieStorage: CookieStorage = {
201+
getCookie: cookie,
202+
setCookie: (name, value, ttl, path, domain, samesite, secure) => {
203+
cookie(name, value, ttl, path, domain, samesite, secure);
204+
return document.cookie.indexOf(`${name}=`) !== -1;
205+
},
206+
deleteCookie
207+
};

libraries/browser-tracker-core/src/tracker/index.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@ import {
1616
getReferrer,
1717
addEventListener,
1818
getHostName,
19-
cookie,
2019
attemptGetLocalStorage,
2120
attemptWriteLocalStorage,
2221
attemptDeleteLocalStorage,
23-
deleteCookie,
2422
fixupTitle,
2523
fromQuerystring,
2624
isInteger,
@@ -69,6 +67,7 @@ import {
6967
} from './id_cookie';
7068
import { CLIENT_SESSION_SCHEMA, WEB_PAGE_SCHEMA, BROWSER_CONTEXT_SCHEMA } from './schemata';
7169
import { getBrowserProperties } from '../helpers/browser_props';
70+
import { asyncCookieStorage, syncCookieStorage } from './cookie_storage';
7271

7372
declare global {
7473
interface Navigator {
@@ -172,6 +171,9 @@ export function Tracker(
172171
};
173172
};
174173

174+
// Create a new cookie storage instance with synchronous cookie write if configured
175+
const cookieStorage = trackerConfiguration.synchronousCookieWrite ? syncCookieStorage : asyncCookieStorage;
176+
175177
// Get all injected plugins
176178
browserPlugins.push(getBrowserDataPlugin());
177179
/* When including the Web Page context, we add the relevant internal plugins */
@@ -479,7 +481,7 @@ export function Tracker(
479481
if (configStateStorageStrategy == 'localStorage') {
480482
return attemptGetLocalStorage(fullName);
481483
} else if (configStateStorageStrategy == 'cookie' || configStateStorageStrategy == 'cookieAndLocalStorage') {
482-
return cookie(fullName);
484+
return cookieStorage.getCookie(fullName);
483485
}
484486
return undefined;
485487
}
@@ -606,8 +608,7 @@ export function Tracker(
606608
if (configStateStorageStrategy == 'localStorage') {
607609
return attemptWriteLocalStorage(name, value, timeout);
608610
} else if (configStateStorageStrategy == 'cookie' || configStateStorageStrategy == 'cookieAndLocalStorage') {
609-
cookie(name, value, timeout, configCookiePath, configCookieDomain, configCookieSameSite, configCookieSecure);
610-
return document.cookie.indexOf(`${name}=`) !== -1 ? true : false;
611+
return cookieStorage.setCookie(name, value, timeout, configCookiePath, configCookieDomain, configCookieSameSite, configCookieSecure);
611612
}
612613
return false;
613614
}
@@ -620,8 +621,8 @@ export function Tracker(
620621
const sesname = getSnowplowCookieName('ses');
621622
attemptDeleteLocalStorage(idname);
622623
attemptDeleteLocalStorage(sesname);
623-
deleteCookie(idname, configCookieDomain, configCookieSameSite, configCookieSecure);
624-
deleteCookie(sesname, configCookieDomain, configCookieSameSite, configCookieSecure);
624+
cookieStorage.deleteCookie(idname, configCookiePath, configCookieDomain, configCookieSameSite, configCookieSecure);
625+
cookieStorage.deleteCookie(sesname, configCookiePath, configCookieDomain, configCookieSameSite, configCookieSecure);
625626
if (!configuration?.preserveSession) {
626627
memorizedSessionId = uuid();
627628
memorizedVisitCount = 1;
@@ -832,7 +833,7 @@ export function Tracker(
832833
const isFirstEventInSession = eventIndexFromIdCookie(idCookie) === 0;
833834

834835
if (configOptOutCookie) {
835-
toOptoutByCookie = !!cookie(configOptOutCookie);
836+
toOptoutByCookie = !!cookieStorage.getCookie(configOptOutCookie);
836837
} else {
837838
toOptoutByCookie = false;
838839
}
@@ -1299,7 +1300,7 @@ export function Tracker(
12991300
},
13001301

13011302
setUserIdFromCookie: function (cookieName: string) {
1302-
businessUserId = cookie(cookieName);
1303+
businessUserId = cookieStorage.getCookie(cookieName);
13031304
},
13041305

13051306
setCollectorUrl: function (collectorUrl: string) {

libraries/browser-tracker-core/src/tracker/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,15 @@ export type TrackerConfiguration = {
190190
* Defaults to `false`.
191191
*/
192192
preservePageViewIdForUrl?: PreservePageViewIdForUrl;
193+
194+
/**
195+
* Whether to write the cookies synchronously.
196+
* This can be useful for testing purposes to ensure that the cookies are written before the test continues.
197+
* It also has the benefit of making sure that the cookie is correctly set before session information is used in events.
198+
* The downside is that it is slower and blocks the main thread.
199+
* @defaultValue false
200+
*/
201+
synchronousCookieWrite?: boolean;
193202
} & EmitterConfigurationBase &
194203
LocalStorageEventStoreConfigurationBase;
195204

0 commit comments

Comments
 (0)