Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/core/src/browser/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { display } from '../tools/display'
import { ONE_MINUTE, ONE_SECOND } from '../tools/utils/timeUtils'
import { findCommaSeparatedValue, findCommaSeparatedValues, generateUUID } from '../tools/utils/stringUtils'
import {
findAllCommaSeparatedValues,
findCommaSeparatedValue,
findCommaSeparatedValues,
generateUUID,
} from '../tools/utils/stringUtils'
import { buildUrl } from '../tools/utils/urlPolyfill'

export interface CookieOptions {
Expand All @@ -21,15 +26,28 @@ export function setCookie(name: string, value: string, expireDelay: number = 0,
document.cookie = `${name}=${value};${expires};path=/;samesite=${sameSite}${domain}${secure}${partitioned}`
}

/**
* Returns the value of the cookie with the given name
* If there are multiple cookies with the same name, returns the first one
*/
export function getCookie(name: string) {
return findCommaSeparatedValue(document.cookie, name)
}

/**
* Returns all the values of the cookies with the given name
*/
export function getCookies(name: string): string[] {
return findAllCommaSeparatedValues(document.cookie).get(name) || []
}

let initCookieParsed: Map<string, string> | undefined

/**
* Returns a cached value of the cookie. Use this during SDK initialization (and whenever possible)
* to avoid accessing document.cookie multiple times.
*
* ⚠️ If there are multiple cookies with the same name, returns the LAST one (unlike `getCookie()`)
*/
export function getInitCookie(name: string) {
if (!initCookieParsed) {
Expand Down
30 changes: 24 additions & 6 deletions packages/core/src/domain/session/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ import { getCurrentSite } from '../../browser/cookie'
import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures'
import { findLast } from '../../tools/utils/polyfills'
import { monitorError } from '../../tools/monitor'
import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY } from './sessionConstants'
import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants'
import { startSessionStore } from './sessionStore'
import type { SessionState } from './sessionState'
import { toSessionState } from './sessionState'
import { retrieveSessionCookie } from './storeStrategies/sessionInCookie'
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'
import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage'

export interface SessionManager<TrackingType extends string> {
findSession: (
Expand Down Expand Up @@ -107,7 +108,7 @@ export function startSessionManager<TrackingType extends string>(
const session = sessionStore.getSession()

if (!session) {
reportUnexpectedSessionState().catch(() => void 0) // Ignore errors
reportUnexpectedSessionState(configuration).catch(() => void 0) // Ignore errors

return {
id: 'invalid',
Expand Down Expand Up @@ -172,16 +173,33 @@ function trackResume(configuration: Configuration, cb: () => void) {
stopCallbacks.push(stop)
}

async function reportUnexpectedSessionState() {
const rawSession = retrieveSessionCookie()
async function reportUnexpectedSessionState(configuration: Configuration) {
const sessionStoreStrategyType = configuration.sessionStoreStrategyType
if (!sessionStoreStrategyType) {
return
}

let rawSession
let cookieContext

if (sessionStoreStrategyType.type === SessionPersistence.COOKIE) {
rawSession = retrieveSessionCookie(sessionStoreStrategyType.cookieOptions)

cookieContext = {
cookie: await getSessionCookies(),
currentDomain: `${window.location.protocol}//${window.location.hostname}`,
}
} else {
rawSession = retrieveSessionFromLocalStorage()
}
// monitor-until: forever, could be handy to troubleshoot issues until session manager rework
addTelemetryDebug('Unexpected session state', {
sessionStoreStrategyType: sessionStoreStrategyType.type,
session: rawSession,
isSyntheticsTest: isSyntheticsTest(),
createdTimestamp: rawSession?.created,
expireTimestamp: rawSession?.expire,
cookie: await getSessionCookies(),
currentDomain: `${window.location.protocol}//${window.location.hostname}`,
...cookieContext,
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import { mockClock, getSessionState } from '../../../../test'
import { ExperimentalFeature } from '../../../tools/experimentalFeatures'
import { mockClock, getSessionState, registerCleanupTask, mockExperimentalFeatures } from '../../../../test'
import { setCookie, deleteCookie, getCookie, getCurrentSite } from '../../../browser/cookie'
import type { SessionState } from '../sessionState'
import type { Configuration } from '../../configuration'
import { validateAndBuildConfiguration } from '../../configuration'
import type { InitConfiguration } from '../../configuration'
import { SESSION_COOKIE_EXPIRATION_DELAY, SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from '../sessionConstants'
import { buildCookieOptions, selectCookieStrategy, initCookieStrategy } from './sessionInCookie'
import type { SessionStoreStrategy } from './sessionStoreStrategy'
import { SESSION_STORE_KEY } from './sessionStoreStrategy'

export const DEFAULT_INIT_CONFIGURATION = { trackAnonymousUser: true } as Configuration
describe('session in cookie strategy', () => {
const sessionState: SessionState = { id: '123', created: '0' }
let cookieStorageStrategy: SessionStoreStrategy
const DEFAULT_INIT_CONFIGURATION = { clientToken: 'abc', trackAnonymousUser: true }

beforeEach(() => {
cookieStorageStrategy = initCookieStrategy(DEFAULT_INIT_CONFIGURATION, {})
})
function setupCookieStrategy(partialInitConfiguration: Partial<InitConfiguration> = {}) {
const initConfiguration = {
...DEFAULT_INIT_CONFIGURATION,
...partialInitConfiguration,
} as InitConfiguration

afterEach(() => {
deleteCookie(SESSION_STORE_KEY)
})
const configuration = validateAndBuildConfiguration(initConfiguration)!
const cookieOptions = buildCookieOptions(initConfiguration)!

registerCleanupTask(() => deleteCookie(SESSION_STORE_KEY, cookieOptions))

return initCookieStrategy(configuration, cookieOptions)
}

describe('session in cookie strategy', () => {
const sessionState: SessionState = { id: '123', created: '0' }

it('should persist a session in a cookie', () => {
const cookieStorageStrategy = setupCookieStrategy()
cookieStorageStrategy.persistSession(sessionState)
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({ ...sessionState })
expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0')
})

it('should set `isExpired=1` and `aid` to the cookie holding the session', () => {
const cookieStorageStrategy = setupCookieStrategy()
spyOn(Math, 'random').and.callFake(() => 0)
cookieStorageStrategy.persistSession(sessionState)
cookieStorageStrategy.expireSession(sessionState)
Expand All @@ -37,6 +46,7 @@ describe('session in cookie strategy', () => {
})

it('should return an empty object if session string is invalid', () => {
const cookieStorageStrategy = setupCookieStrategy()
setCookie(SESSION_STORE_KEY, '{test:42}', 1000)
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({})
Expand Down Expand Up @@ -101,37 +111,69 @@ describe('session in cookie strategy', () => {
})
})
})

describe('encode cookie options', () => {
beforeEach(() => {
mockExperimentalFeatures([ExperimentalFeature.ENCODE_COOKIE_OPTIONS])
})

it('should encode cookie options in the cookie value', () => {
// Some older browsers don't support partitioned cross-site session cookies
// so instead of testing the cookie value, we test the call to the cookie setter
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })
cookieStorageStrategy.persistSession({ id: '123' })

const calls = cookieSetSpy.calls.all()
const lastCall = calls[calls.length - 1]
expect(lastCall.args[0]).toMatch(/^_dd_s=id=123&c=1/)
})

it('should not encode cookie options in the cookie value if the session is empty (deleting the cookie)', () => {
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })
cookieStorageStrategy.persistSession({})

expect(getCookie(SESSION_STORE_KEY)).toBeUndefined()
})

it('should return the correct session state from the cookies', () => {
spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=456&c=1;_dd_s=id=789&c=2')
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })

expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '456' })
})

it('should return the session state from the first cookie if there is no match', () => {
spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=789&c=2')
const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true })

expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '123' })
})
})
})

describe('session in cookie strategy when opt-in anonymous user tracking', () => {
const anonymousId = 'device-123'
const sessionState: SessionState = { id: '123', created: '0' }
let cookieStorageStrategy: SessionStoreStrategy
beforeEach(() => {
cookieStorageStrategy = initCookieStrategy(
{ ...DEFAULT_INIT_CONFIGURATION, trackAnonymousUser: true } as Configuration,
{}
)
})

afterEach(() => {
deleteCookie(SESSION_STORE_KEY)
})
it('should persist with anonymous id', () => {
const cookieStorageStrategy = setupCookieStrategy()
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({ ...sessionState, anonymousId })
expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0&aid=device-123')
})

it('should expire with anonymous id', () => {
const cookieStorageStrategy = setupCookieStrategy()
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
const session = cookieStorageStrategy.retrieveSession()
expect(session).toEqual({ isExpired: '1', anonymousId })
expect(getCookie(SESSION_STORE_KEY)).toBe('isExpired=1&aid=device-123')
})

it('should persist for one year when opt-in', () => {
const cookieStorageStrategy = setupCookieStrategy()
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
const clock = mockClock()
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
Expand All @@ -141,6 +183,7 @@ describe('session in cookie strategy when opt-in anonymous user tracking', () =>
})

it('should expire in one year when opt-in', () => {
const cookieStorageStrategy = setupCookieStrategy()
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
const clock = mockClock()
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
Expand All @@ -153,30 +196,24 @@ describe('session in cookie strategy when opt-in anonymous user tracking', () =>
describe('session in cookie strategy when opt-out anonymous user tracking', () => {
const anonymousId = 'device-123'
const sessionState: SessionState = { id: '123', created: '0' }
let cookieStorageStrategy: SessionStoreStrategy

beforeEach(() => {
cookieStorageStrategy = initCookieStrategy({ trackAnonymousUser: false } as Configuration, {})
})

afterEach(() => {
deleteCookie(SESSION_STORE_KEY)
})

it('should not extend cookie expiration time when opt-out', () => {
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
const clock = mockClock()
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
expect(cookieSetSpy.calls.argsFor(0)[0]).toContain(new Date(clock.timeStamp(SESSION_TIME_OUT_DELAY)).toUTCString())
})

it('should not persist with one year when opt-out', () => {
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
const cookieSetSpy = spyOnProperty(document, 'cookie', 'set')
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
expect(cookieSetSpy.calls.argsFor(0)[0]).toContain(new Date(Date.now() + SESSION_EXPIRATION_DELAY).toUTCString())
})

it('should not persist or expire a session with anonymous id when opt-out', () => {
const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false })
cookieStorageStrategy.persistSession({ ...sessionState, anonymousId })
cookieStorageStrategy.expireSession({ ...sessionState, anonymousId })
const session = cookieStorageStrategy.retrieveSession()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { isExperimentalFeatureEnabled, ExperimentalFeature } from '../../../tools/experimentalFeatures'
import { isEmptyObject } from '../../../tools/utils/objectUtils'
import { isChromium } from '../../../tools/utils/browserDetection'
import type { CookieOptions } from '../../../browser/cookie'
import { getCurrentSite, areCookiesAuthorized, getCookie, setCookie } from '../../../browser/cookie'
import { getCurrentSite, areCookiesAuthorized, getCookies, setCookie, getCookie } from '../../../browser/cookie'
import type { InitConfiguration, Configuration } from '../../configuration'
import { tryOldCookiesMigration } from '../oldCookiesMigration'
import {
Expand All @@ -14,6 +16,8 @@ import { toSessionString, toSessionState, getExpiredSessionState } from '../sess
import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy'
import { SESSION_STORE_KEY } from './sessionStoreStrategy'

const SESSION_COOKIE_VERSION = 0

export function selectCookieStrategy(initConfiguration: InitConfiguration): SessionStoreStrategyType | undefined {
const cookieOptions = buildCookieOptions(initConfiguration)
return cookieOptions && areCookiesAuthorized(cookieOptions)
Expand All @@ -30,7 +34,7 @@ export function initCookieStrategy(configuration: Configuration, cookieOptions:
isLockEnabled: isChromium(),
persistSession: (sessionState: SessionState) =>
storeSessionCookie(cookieOptions, configuration, sessionState, SESSION_EXPIRATION_DELAY),
retrieveSession: retrieveSessionCookie,
retrieveSession: () => retrieveSessionCookie(cookieOptions),
expireSession: (sessionState: SessionState) =>
storeSessionCookie(
cookieOptions,
Expand All @@ -51,15 +55,34 @@ function storeSessionCookie(
sessionState: SessionState,
defaultTimeout: number
) {
let sessionStateString = toSessionString(sessionState)

if (isExperimentalFeatureEnabled(ExperimentalFeature.ENCODE_COOKIE_OPTIONS)) {
sessionStateString = toSessionString({
...sessionState,
// deleting a cookie is writing a new cookie with an empty value
// we don't want to store the cookie options in this case otherwise the cookie will not be deleted
...(!isEmptyObject(sessionState) ? { c: encodeCookieOptions(options) } : {}),
})
}

setCookie(
SESSION_STORE_KEY,
toSessionString(sessionState),
sessionStateString,
configuration.trackAnonymousUser ? SESSION_COOKIE_EXPIRATION_DELAY : defaultTimeout,
options
)
}

export function retrieveSessionCookie(): SessionState {
/**
* Retrieve the session state from the cookie that was set with the same cookie options
* If there is no match, return the first cookie, because that's how `getCookie()` works
*/
export function retrieveSessionCookie(cookieOptions: CookieOptions): SessionState {
if (isExperimentalFeatureEnabled(ExperimentalFeature.ENCODE_COOKIE_OPTIONS)) {
return retrieveSessionCookieFromEncodedCookie(cookieOptions)
}

const sessionString = getCookie(SESSION_STORE_KEY)
const sessionState = toSessionState(sessionString)
return sessionState
Expand All @@ -83,3 +106,42 @@ export function buildCookieOptions(initConfiguration: InitConfiguration): Cookie

return cookieOptions
}

function encodeCookieOptions(cookieOptions: CookieOptions): string {
const domainCount = cookieOptions.domain ? cookieOptions.domain.split('.').length - 1 : 0

/* eslint-disable no-bitwise */
let byte = 0
byte |= SESSION_COOKIE_VERSION << 5 // Store version in upper 3 bits
byte |= domainCount << 1 // Store domain count in next 4 bits
byte |= cookieOptions.crossSite ? 1 : 0 // Store useCrossSiteScripting in next bit
/* eslint-enable no-bitwise */

return byte.toString(16) // Convert to hex string
}

/**
* Retrieve the session state from the cookie that was set with the same cookie options.
* If there is no match, fallback to the first cookie, (because that's how `getCookie()` works)
* and this allows to keep the current session id when we release this feature.
*/
function retrieveSessionCookieFromEncodedCookie(cookieOptions: CookieOptions): SessionState {
const cookies = getCookies(SESSION_STORE_KEY)
const opts = encodeCookieOptions(cookieOptions)

let sessionState: SessionState | undefined

// reverse the cookies so that if there is no match, the cookie returned is the first one
for (const cookie of cookies.reverse()) {
sessionState = toSessionState(cookie)

if (sessionState.c === opts) {
break
}
}

// remove the cookie options from the session state as this is not part of the session state
delete sessionState?.c

return sessionState ?? {}
}
Loading